Spaces:
Running
Running
| """OpenAI 兼容适配层 | |
| 将 OpenAI Chat Completion 格式转换为 Gemini 格式(请求), | |
| 将 Gemini SSE 格式转换为 OpenAI 格式(响应)。 | |
| """ | |
| import json | |
| import math | |
| import re | |
| import time | |
| import uuid | |
| import copy | |
| from typing import Any | |
| from src.utils.logger import get_logger | |
| logger = get_logger(__name__) | |
| DEFAULT_IMAGE_MODEL = "gemini-2.5-flash-image" | |
| OPENAI_IMAGE_MODEL_ALIASES = { | |
| "gpt-image-1", | |
| "dall-e-2", | |
| "dall-e-3", | |
| } | |
| FINISH_REASON_MAP = { | |
| "STOP": "stop", | |
| "MAX_TOKENS": "length", | |
| "SAFETY": "content_filter", | |
| "RECITATION": "content_filter", | |
| "TOOL_CALLS": "tool_calls", | |
| "MALFORMED_FUNCTION_CALL": "tool_calls", | |
| "BLOCKLIST": "content_filter", | |
| "PROHIBITED_CONTENT": "content_filter", | |
| "SPII": "content_filter", | |
| "OTHER": "stop", | |
| } | |
| _OPENAI_WEB_SEARCH_TOOL_TYPES = { | |
| "web_search", | |
| "web_search_preview", | |
| "web_search_preview_2025_03_11", | |
| "google_search", | |
| } | |
| _OPENAI_CODE_TOOL_TYPES = {"code_interpreter", "code_execution"} | |
| _OPENAI_URL_CONTEXT_TOOL_TYPES = {"url_context", "urlcontext"} | |
| _OPENAI_FILE_SEARCH_TOOL_TYPES = {"file_search", "retrieval"} | |
| _OPENAI_FALLBACK_TOOL_TYPES = { | |
| "computer_use", | |
| "computer_use_preview", | |
| "mcp", | |
| "custom", | |
| } | |
| _GEMINI_FUNCTION_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]+") | |
| class OAIRequestConverter: | |
| """OpenAI → Gemini 请求转换""" | |
| def convert(body: dict[str, Any]) -> tuple[str, dict[str, Any]]: | |
| """将 OAI ChatCompletion 请求转为 (model, gemini_payload)""" | |
| model = body["model"] | |
| messages = body.get("messages", []) | |
| contents: list[dict[str, Any]] = [] | |
| system_parts: list[dict[str, str]] = [] | |
| tool_call_name_by_id: dict[str, str] = {} | |
| for msg in messages: | |
| role = msg.get("role", "") | |
| content = msg.get("content") | |
| if role in {"system", "developer"}: | |
| system_text = _content_to_text(content) | |
| if system_text: | |
| system_parts.append({"text": system_text}) | |
| elif role == "user": | |
| parts = _convert_content_to_parts(content) | |
| if parts: | |
| contents.append({"role": "user", "parts": parts}) | |
| elif role == "assistant": | |
| parts: list[dict[str, Any]] = [] | |
| assistant_text = _content_to_text(content) | |
| if assistant_text: | |
| parts.append({"text": assistant_text}) | |
| tool_calls = msg.get("tool_calls") | |
| if tool_calls: | |
| for tc in tool_calls: | |
| parsed = _extract_oai_tool_call(tc) | |
| if not parsed: | |
| continue | |
| tc_id, func_name, args_obj, thought_signature = parsed | |
| if tc_id: | |
| tool_call_name_by_id[str(tc_id)] = func_name | |
| function_call_part: dict[str, Any] = {"functionCall": {"name": func_name, "args": args_obj}} | |
| if thought_signature: | |
| function_call_part["thoughtSignature"] = thought_signature | |
| parts.append(function_call_part) | |
| if parts: | |
| contents.append({"role": "model", "parts": parts}) | |
| elif role == "tool": | |
| tool_call_id = msg.get("tool_call_id") | |
| name = msg.get("name") or tool_call_name_by_id.get(str(tool_call_id), "unknown") | |
| raw = msg.get("content", "") | |
| resp_obj = _parse_tool_response(raw) | |
| contents.append({ | |
| "role": "function", | |
| "parts": [{"functionResponse": {"name": name, "response": resp_obj}}] | |
| }) | |
| elif role == "function": | |
| name = msg.get("name") or "unknown" | |
| resp_obj = _parse_tool_response(msg.get("content", "")) | |
| contents.append({ | |
| "role": "function", | |
| "parts": [{"functionResponse": {"name": name, "response": resp_obj}}] | |
| }) | |
| gemini_payload: dict[str, Any] = {"contents": contents} | |
| if system_parts: | |
| gemini_payload["systemInstruction"] = {"parts": system_parts} | |
| # tools:OpenAI function tools 直接映射为 Gemini functionDeclarations; | |
| # OpenAI web/code/url 等内建工具尽量映射为 Gemini 原生工具,其他工具降级为函数声明。 | |
| oai_tools = body.get("tools") | |
| if not oai_tools and body.get("functions"): | |
| oai_tools = [{"type": "function", "function": f} for f in body.get("functions", [])] | |
| declared_tool_names: set[str] = set() | |
| native_tools: list[dict[str, Any]] = [] | |
| if oai_tools: | |
| func_decls = [] | |
| for t in oai_tools: | |
| f = _extract_oai_function_tool(t) | |
| if f: | |
| decl = _oai_function_to_gemini_declaration(f) | |
| declared_tool_names.add(str(decl["name"])) | |
| func_decls.append(decl) | |
| else: | |
| mapped_tools = _map_oai_tool_to_gemini_tools(t) | |
| native_tools.extend(mapped_tools) | |
| for mapped_tool in mapped_tools: | |
| for mapped_decl in mapped_tool.get("functionDeclarations", []) if isinstance(mapped_tool, dict) else []: | |
| if isinstance(mapped_decl, dict) and mapped_decl.get("name"): | |
| declared_tool_names.add(str(mapped_decl["name"])) | |
| gemini_tools: list[dict[str, Any]] = [] | |
| if func_decls: | |
| gemini_tools.append({"functionDeclarations": func_decls}) | |
| gemini_tools.extend(native_tools) | |
| if gemini_tools: | |
| gemini_payload["tools"] = gemini_tools | |
| # tool_choice | |
| tc = body.get("tool_choice", body.get("function_call")) | |
| if tc: | |
| tc_value = tc.lower() if isinstance(tc, str) else tc | |
| if tc_value == "none": | |
| gemini_payload.pop("tools", None) | |
| gemini_payload.pop("toolConfig", None) | |
| elif tc_value == "auto": | |
| if declared_tool_names: | |
| gemini_payload["toolConfig"] = {"functionCallingConfig": {"mode": "AUTO"}} | |
| elif isinstance(tc_value, str) and tc_value in {"required", "any"}: | |
| if declared_tool_names: | |
| gemini_payload["toolConfig"] = {"functionCallingConfig": {"mode": "ANY"}} | |
| elif isinstance(tc, dict) and (tc.get("type") == "function" or "name" in tc): | |
| fn_name = tc.get("function", {}).get("name") if tc.get("type") == "function" else tc.get("name") | |
| if fn_name: | |
| fn_name = _sanitize_gemini_function_name(str(fn_name)) | |
| if fn_name not in declared_tool_names: | |
| _ensure_synthetic_function_tool(gemini_payload, fn_name) | |
| declared_tool_names.add(fn_name) | |
| gemini_payload["toolConfig"] = { | |
| "functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": [fn_name]} | |
| } | |
| elif isinstance(tc, dict): | |
| # OpenAI/Responses 的非 function tool_choice(如 web_search/code_interpreter) | |
| # Gemini 没有等价的强制选择配置;保留 tools 映射,不额外发送 functionCallingConfig。 | |
| pass | |
| else: | |
| raise ValueError(f"Unsupported tool_choice/function_call: {tc}") | |
| # generationConfig | |
| gen_cfg: dict[str, Any] = {} | |
| for oai_key, gemini_key in [ | |
| ("temperature", "temperature"), | |
| ("top_p", "topP"), | |
| ("top_k", "topK"), | |
| ("presence_penalty", "presencePenalty"), | |
| ("frequency_penalty", "frequencyPenalty"), | |
| ("seed", "seed"), | |
| ]: | |
| if oai_key in body and body[oai_key] is not None: | |
| gen_cfg[gemini_key] = body[oai_key] | |
| n_val = body.get("n") | |
| if n_val is not None and isinstance(n_val, int) and n_val > 1: | |
| gen_cfg["candidateCount"] = n_val | |
| max_tokens = body.get("max_tokens") or body.get("max_completion_tokens") | |
| if max_tokens is not None: | |
| gen_cfg["maxOutputTokens"] = max_tokens | |
| if body.get("logprobs") is not None: | |
| gen_cfg["responseLogprobs"] = bool(body.get("logprobs")) | |
| if body.get("top_logprobs") is not None: | |
| gen_cfg["logprobs"] = body.get("top_logprobs") | |
| stop = body.get("stop") | |
| if stop is not None: | |
| gen_cfg["stopSequences"] = [stop] if isinstance(stop, str) else stop | |
| rf = body.get("response_format") | |
| if isinstance(rf, dict): | |
| rf_type = rf.get("type") | |
| if rf_type == "json_object": | |
| gen_cfg["responseMimeType"] = "application/json" | |
| elif rf_type == "json_schema": | |
| gen_cfg["responseMimeType"] = "application/json" | |
| schema = rf.get("json_schema", {}).get("schema") | |
| if schema: | |
| gen_cfg["responseSchema"] = _sanitize_schema_for_gemini(schema) | |
| modalities = body.get("modalities") or body.get("response_modalities") | |
| if isinstance(modalities, list): | |
| converted_modalities = [_convert_modality(m) for m in modalities if _convert_modality(m)] | |
| if converted_modalities: | |
| gen_cfg["responseModalities"] = converted_modalities | |
| elif _looks_like_image_model(model): | |
| gen_cfg.setdefault("responseModalities", ["TEXT", "IMAGE"]) | |
| if isinstance(n_val, int) and n_val > 1: | |
| response_modalities = gen_cfg.get("responseModalities") | |
| if isinstance(response_modalities, list) and any(str(item).upper() == "IMAGE" for item in response_modalities): | |
| image_config = gen_cfg.get("imageConfig") | |
| if not isinstance(image_config, dict): | |
| image_config = {} | |
| image_config["numberOfImages"] = n_val | |
| gen_cfg["imageConfig"] = image_config | |
| gen_cfg.pop("candidateCount", None) | |
| if gen_cfg: | |
| gemini_payload["generationConfig"] = gen_cfg | |
| oai_safety = body.get("safety_settings") or body.get("safetySettings") | |
| if isinstance(oai_safety, list): | |
| gemini_payload["safetySettings"] = oai_safety | |
| labels = body.get("labels") or body.get("metadata") | |
| if isinstance(labels, dict): | |
| gemini_payload["labels"] = {str(k): str(v)[:63] for k, v in labels.items() if v is not None} | |
| cached_content = body.get("cached_content") or body.get("cachedContent") | |
| if isinstance(cached_content, str) and cached_content: | |
| gemini_payload["cachedContent"] = cached_content | |
| return model, gemini_payload | |
| class OAIImageRequestConverter: | |
| """OpenAI Images API → Gemini 图片生成请求转换""" | |
| def resolve_model(model: Any) -> str: | |
| if not model: | |
| return DEFAULT_IMAGE_MODEL | |
| model_str = str(model) | |
| if model_str in OPENAI_IMAGE_MODEL_ALIASES: | |
| return DEFAULT_IMAGE_MODEL | |
| return model_str | |
| def convert_generation(body: dict[str, Any]) -> tuple[str, dict[str, Any], int, str]: | |
| model = OAIImageRequestConverter.resolve_model(body.get("model")) | |
| prompt = _append_negative_prompt(str(body.get("prompt") or ""), body.get("negative_prompt")) | |
| if not prompt.strip(): | |
| raise ValueError("prompt is required") | |
| n = _coerce_positive_int(body.get("n"), default=1, maximum=8) | |
| response_format = str(body.get("response_format") or "b64_json") | |
| payload = OAIImageRequestConverter.build_payload( | |
| model=model, | |
| prompt=prompt, | |
| n=n, | |
| images=[], | |
| mask=None, | |
| size=body.get("size"), | |
| quality=body.get("quality"), | |
| style=body.get("style"), | |
| background=body.get("background"), | |
| output_format=body.get("output_format"), | |
| ) | |
| return model, payload, n, response_format | |
| def build_payload( | |
| model: str, | |
| prompt: str, | |
| n: int = 1, | |
| images: list[dict[str, str]] | None = None, | |
| mask: dict[str, str] | None = None, | |
| size: Any = None, | |
| quality: Any = None, | |
| style: Any = None, | |
| background: Any = None, | |
| output_format: Any = None, | |
| mode: str = "generation", | |
| ) -> dict[str, Any]: | |
| prompt_text = _build_image_prompt( | |
| prompt=prompt, | |
| size=size, | |
| quality=quality, | |
| style=style, | |
| background=background, | |
| mode=mode, | |
| has_mask=bool(mask), | |
| ) | |
| parts: list[dict[str, Any]] = [{"text": prompt_text}] | |
| for image in images or []: | |
| if image.get("data") and image.get("mimeType"): | |
| parts.append({"inlineData": {"mimeType": image["mimeType"], "data": _normalize_base64(image["data"])}}) | |
| if mask and mask.get("data") and mask.get("mimeType"): | |
| parts.append({"text": "Use the following image as the edit mask when applying the requested change."}) | |
| parts.append({"inlineData": {"mimeType": mask["mimeType"], "data": _normalize_base64(mask["data"])}}) | |
| generation_config: dict[str, Any] = { | |
| "responseModalities": ["TEXT", "IMAGE"], | |
| } | |
| image_config: dict[str, Any] = {} | |
| if n > 1: | |
| image_config["numberOfImages"] = n | |
| aspect_ratio = _size_to_aspect_ratio(size) | |
| if aspect_ratio: | |
| image_config["aspectRatio"] = aspect_ratio | |
| image_size = _size_to_image_size(size) | |
| if image_size and "gemini-3" in model: | |
| image_config["imageSize"] = image_size | |
| if image_config: | |
| generation_config["imageConfig"] = image_config | |
| return { | |
| "contents": [{"role": "user", "parts": parts}], | |
| "generationConfig": generation_config, | |
| } | |
| class OAIResponseConverter: | |
| """Gemini → OpenAI 响应转换""" | |
| def _extract_images_from_parts(parts: list[dict[str, Any]]) -> list[dict[str, str]]: | |
| images: list[dict[str, str]] = [] | |
| for part in parts: | |
| image = _extract_image_from_part(part) | |
| if image: | |
| images.append(image) | |
| return images | |
| def gemini_chunk_to_openai_image_partial( | |
| chunk: dict[str, Any], | |
| response_format: str = "b64_json", | |
| ) -> list[dict[str, Any]]: | |
| """从 Gemini 流式 chunk 中提取符合 OpenAI Images data item 的图片。""" | |
| items: list[dict[str, Any]] = [] | |
| for part in _iter_response_parts(chunk): | |
| image = _extract_image_from_part(part) | |
| if not image: | |
| continue | |
| if image["kind"] == "file": | |
| items.append({"url": image["url"]}) | |
| else: | |
| items.append({"b64_json": image["data"]}) | |
| return items | |
| def convert_realtime_chunk( | |
| chunk: dict[str, Any], | |
| model: str, | |
| request_id: str, | |
| is_first: bool, | |
| has_prior_tool_calls: bool = False, | |
| ) -> list[str]: | |
| """将单个 Gemini 增量 dict 转为 OAI SSE 事件列表(真流式用)""" | |
| candidates = chunk.get("candidates", []) | |
| if not candidates: | |
| candidates = [{}] | |
| usage_meta = chunk.get("usageMetadata") | |
| created = int(time.time()) | |
| base = {"id": f"chatcmpl-{request_id}", "object": "chat.completion.chunk", "created": created, "model": model} | |
| events: list[str] = [] | |
| if is_first: | |
| first_choices = [] | |
| for i in range(len(candidates)): | |
| first_choices.append({"index": i, "delta": {"role": "assistant"}, "finish_reason": None}) | |
| if first_choices: | |
| events.append(_sse_line({**base, "choices": first_choices})) | |
| for i, candidate in enumerate(candidates): | |
| parts = (candidate.get("content") or {}).get("parts", []) | |
| finish = candidate.get("finishReason") | |
| text_content, tool_calls, reasoning = _extract_parts(parts, for_stream=True) | |
| image_items = OAIResponseConverter._extract_images_from_parts(parts) | |
| if reasoning: | |
| events.append(_sse_line({**base, "choices": [{"index": i, "delta": {"reasoning_content": reasoning}, "finish_reason": None}]})) | |
| if text_content: | |
| events.append(_sse_line({**base, "choices": [{"index": i, "delta": {"content": text_content}, "finish_reason": None}]})) | |
| if image_items: | |
| events.append(_sse_line({**base, "choices": [{"index": i, "delta": {"images": [ | |
| _image_to_oai_chat_image(image) | |
| for image in image_items | |
| ]}, "finish_reason": None}]})) | |
| if tool_calls: | |
| events.append(_sse_line({**base, "choices": [{"index": i, "delta": {"tool_calls": tool_calls}, "finish_reason": None}]})) | |
| if finish: | |
| oai_finish = _map_finish_reason(finish, has_tool_calls=bool(tool_calls) or has_prior_tool_calls) | |
| finish_evt: dict[str, Any] = {**base, "choices": [{"index": i, "delta": {}, "finish_reason": oai_finish}]} | |
| if usage_meta and i == len(candidates) - 1: | |
| finish_evt["usage"] = _convert_usage(usage_meta) | |
| if candidate.get("logprobsResult"): | |
| finish_evt["choices"][0]["logprobs"] = candidate.get("logprobsResult") | |
| events.append(_sse_line(finish_evt)) | |
| return events | |
| def convert_realtime_chunk_objects( | |
| chunk: dict[str, Any], | |
| model: str, | |
| request_id: str, | |
| is_first: bool, | |
| has_prior_tool_calls: bool = False, | |
| ) -> list[dict[str, Any]]: | |
| """将单个 Gemini 增量 dict 转为 OAI SSE payload 对象列表。""" | |
| candidates = chunk.get("candidates", []) | |
| if not candidates: | |
| candidates = [{}] | |
| usage_meta = chunk.get("usageMetadata") | |
| created = int(time.time()) | |
| base = {"id": f"chatcmpl-{request_id}", "object": "chat.completion.chunk", "created": created, "model": model} | |
| payloads: list[dict[str, Any]] = [] | |
| if is_first: | |
| first_choices = [] | |
| for i in range(len(candidates)): | |
| first_choices.append({"index": i, "delta": {"role": "assistant"}, "finish_reason": None}) | |
| if first_choices: | |
| payloads.append({**base, "choices": first_choices}) | |
| for i, candidate in enumerate(candidates): | |
| parts = (candidate.get("content") or {}).get("parts", []) | |
| finish = candidate.get("finishReason") | |
| text_content, tool_calls, reasoning = _extract_parts(parts, for_stream=True) | |
| image_items = OAIResponseConverter._extract_images_from_parts(parts) | |
| if reasoning: | |
| payloads.append({**base, "choices": [{"index": i, "delta": {"reasoning_content": reasoning}, "finish_reason": None}]}) | |
| if text_content: | |
| # 防止单条 SSE 过大(如内嵌 3MB Base64 图片)撑爆部分客户端的单行读取缓冲区 | |
| # 这里将长文本按 32KB 分块发送 | |
| chunk_size = 32768 | |
| for j in range(0, len(text_content), chunk_size): | |
| text_chunk = text_content[j:j+chunk_size] | |
| payloads.append({**base, "choices": [{"index": i, "delta": {"content": text_chunk}, "finish_reason": None}]}) | |
| if image_items: | |
| payloads.append({**base, "choices": [{"index": i, "delta": {"images": [ | |
| _image_to_oai_chat_image(image) | |
| for image in image_items | |
| ]}, "finish_reason": None}]}) | |
| if tool_calls: | |
| payloads.append({**base, "choices": [{"index": i, "delta": {"tool_calls": tool_calls}, "finish_reason": None}]}) | |
| if finish: | |
| oai_finish = _map_finish_reason(finish, has_tool_calls=bool(tool_calls) or has_prior_tool_calls) | |
| finish_evt: dict[str, Any] = {**base, "choices": [{"index": i, "delta": {}, "finish_reason": oai_finish}]} | |
| if usage_meta and i == len(candidates) - 1: # Usage is attached only to the last candidate's finish | |
| finish_evt["usage"] = _convert_usage(usage_meta) | |
| if candidate.get("logprobsResult"): | |
| finish_evt["choices"][0]["logprobs"] = candidate.get("logprobsResult") | |
| payloads.append(finish_evt) | |
| return payloads | |
| def gemini_sse_to_oai_stream(gemini_chunk: str, model: str, request_id: str) -> list[str]: | |
| """将单条 Gemini SSE 转为多条 OAI SSE 事件(假流式用)""" | |
| data = _parse_gemini_sse(gemini_chunk) | |
| if data is None: | |
| return [] | |
| return OAIResponseConverter.convert_realtime_chunk(data, model, request_id, is_first=True) | |
| def gemini_json_to_oai_json(gemini_response: dict[str, Any], model: str) -> dict[str, Any]: | |
| """将 Gemini 非流式响应转为 OAI ChatCompletion JSON""" | |
| request_id = uuid.uuid4().hex[:24] | |
| candidates = gemini_response.get("candidates", []) | |
| if not candidates: | |
| candidates = [{}] | |
| usage_meta = gemini_response.get("usageMetadata") | |
| choices = [] | |
| for i, candidate in enumerate(candidates): | |
| parts = (candidate.get("content") or {}).get("parts", []) | |
| finish = candidate.get("finishReason") | |
| text_content, tool_calls, reasoning = _extract_parts(parts, for_stream=False) | |
| image_items = OAIResponseConverter._extract_images_from_parts(parts) | |
| oai_finish = _map_finish_reason(finish, has_tool_calls=bool(tool_calls)) if finish else ("tool_calls" if tool_calls else "stop") | |
| message: dict[str, Any] = {"role": "assistant", "content": text_content or None} | |
| if tool_calls: | |
| message["tool_calls"] = tool_calls | |
| if reasoning: | |
| message["reasoning_content"] = reasoning | |
| if image_items: | |
| message["images"] = [ | |
| _image_to_oai_chat_image(image) | |
| for image in image_items | |
| ] | |
| choice = {"index": i, "message": message, "finish_reason": oai_finish} | |
| if candidate.get("logprobsResult"): | |
| choice["logprobs"] = candidate.get("logprobsResult") | |
| choices.append(choice) | |
| result: dict[str, Any] = { | |
| "id": f"chatcmpl-{request_id}", | |
| "object": "chat.completion", | |
| "created": int(time.time()), | |
| "model": model, | |
| "choices": choices, | |
| } | |
| if usage_meta: | |
| result["usage"] = _convert_usage(usage_meta) | |
| return result | |
| def gemini_json_to_oai_image_data(gemini_response: dict[str, Any], response_format: str = "b64_json") -> list[dict[str, Any]]: | |
| """从 Gemini 响应中抽取 OpenAI Images API data 数组。""" | |
| items: list[dict[str, Any]] = [] | |
| raw_data = gemini_response.get("data") | |
| if isinstance(raw_data, list): | |
| for item in raw_data: | |
| if not isinstance(item, dict): | |
| continue | |
| b64_data = item.get("b64_json") | |
| url = item.get("url") | |
| if isinstance(b64_data, str) and b64_data.strip(): | |
| items.append({"b64_json": _normalize_base64(b64_data)}) | |
| elif isinstance(url, str) and url: | |
| items.append({"url": url}) | |
| if items: | |
| return items | |
| for part in _iter_response_parts(gemini_response): | |
| image = _extract_image_from_part(part) | |
| if not image: | |
| continue | |
| if image["kind"] == "file": | |
| items.append({"url": image["url"]}) | |
| else: | |
| items.append({"b64_json": image["data"]}) | |
| return items | |
| # ==================== 内部工具函数 ==================== | |
| def _convert_content_to_parts(content: Any) -> list[dict[str, Any]]: | |
| """将 OpenAI 多模态 message content 转为 Gemini parts""" | |
| if content is None: | |
| return [] | |
| if isinstance(content, str): | |
| return [{"text": content}] | |
| parts: list[dict[str, Any]] = [] | |
| if isinstance(content, list): | |
| for item in content: | |
| if isinstance(item, str): | |
| parts.append({"text": item}) | |
| elif isinstance(item, dict): | |
| t = item.get("type") | |
| if t in {"text", "input_text"}: | |
| text = item.get("text") | |
| if text is not None: | |
| parts.append({"text": str(text)}) | |
| elif t in {"image_url", "input_image"}: | |
| url_obj = item.get("image_url") or item.get("input_image") or {} | |
| url = url_obj.get("url") if isinstance(url_obj, dict) else url_obj | |
| image_part = _image_url_to_part(str(url or "")) | |
| if image_part: | |
| parts.append(image_part) | |
| elif "text" in item and len(item) == 1: | |
| parts.append({"text": str(item["text"])}) | |
| return parts | |
| def _content_to_text(content: Any) -> str: | |
| if content is None: | |
| return "" | |
| if isinstance(content, str): | |
| return content | |
| if isinstance(content, list): | |
| texts: list[str] = [] | |
| for item in content: | |
| if isinstance(item, str): | |
| texts.append(item) | |
| elif isinstance(item, dict): | |
| item_type = item.get("type") | |
| if item_type in {"text", "input_text"} and item.get("text") is not None: | |
| texts.append(str(item["text"])) | |
| elif item.get("text") is not None and len(item) == 1: | |
| texts.append(str(item["text"])) | |
| return "".join(texts) | |
| return str(content) | |
| def _parse_tool_response(raw: Any) -> dict[str, Any]: | |
| if isinstance(raw, dict): | |
| return raw | |
| if isinstance(raw, list): | |
| return {"result": raw} | |
| try: | |
| parsed = json.loads(raw) if isinstance(raw, str) else raw | |
| return parsed if isinstance(parsed, dict) else {"result": parsed} | |
| except json.JSONDecodeError: | |
| return {"result": raw} | |
| def _extract_oai_tool_call(tool_call: Any) -> tuple[str | None, str, Any, str | None] | None: | |
| """兼容标准与常见非标准 OpenAI tool_call 形态。""" | |
| if not isinstance(tool_call, dict): | |
| return None | |
| tc_id = tool_call.get("id") or tool_call.get("tool_call_id") or tool_call.get("call_id") | |
| thought_signature = tool_call.get("thoughtSignature") or tool_call.get("thought_signature") | |
| func = tool_call.get("function") | |
| if isinstance(func, dict): | |
| name = func.get("name") or tool_call.get("name") | |
| args = func.get("arguments", tool_call.get("arguments", tool_call.get("args", {}))) | |
| thought_signature = thought_signature or func.get("thoughtSignature") or func.get("thought_signature") | |
| else: | |
| name = tool_call.get("name") or tool_call.get("function_name") | |
| args = tool_call.get("arguments", tool_call.get("args", {})) | |
| if not name: | |
| return None | |
| return ( | |
| str(tc_id) if tc_id else None, | |
| str(name), | |
| _coerce_function_args(args), | |
| str(thought_signature) if thought_signature else None, | |
| ) | |
| def _extract_oai_function_tool(tool: Any) -> dict[str, Any] | None: | |
| if not isinstance(tool, dict): | |
| return None | |
| tool_type = str(tool.get("type") or "").lower() | |
| if tool_type and tool_type != "function": | |
| return None | |
| if tool.get("type") == "function" and isinstance(tool.get("function"), dict): | |
| function_obj = tool["function"] | |
| return function_obj if function_obj.get("name") else None | |
| if tool.get("function") and isinstance(tool.get("function"), str): | |
| copied = tool.copy() | |
| copied["name"] = copied.pop("function") | |
| return copied if copied.get("name") else None | |
| if tool.get("type") == "function" and tool.get("name"): | |
| return tool | |
| if tool.get("name") and ("parameters" in tool or "description" in tool): | |
| return tool | |
| return None | |
| def _oai_function_to_gemini_declaration(function_obj: dict[str, Any]) -> dict[str, Any]: | |
| """OpenAI function tool → Gemini FunctionDeclaration,并补齐 Vcore 必填字段。""" | |
| raw_name = function_obj.get("name") or function_obj.get("function") or "tool" | |
| name = _sanitize_gemini_function_name(str(raw_name)) | |
| decl: dict[str, Any] = {"name": name} | |
| description = function_obj.get("description") | |
| if description: | |
| decl["description"] = str(description) | |
| parameters = function_obj.get("parameters") or function_obj.get("input_schema") or function_obj.get("inputSchema") | |
| if isinstance(parameters, dict): | |
| decl["parameters"] = _ensure_object_schema(_sanitize_schema_for_gemini(parameters)) | |
| else: | |
| decl["parameters"] = {"type": "object", "properties": {}} | |
| return decl | |
| def _map_oai_tool_to_gemini_tools(tool: Any) -> list[dict[str, Any]]: | |
| """尽量把 OpenAI/Responses 非 function 工具映射到 Gemini 原生工具或安全降级函数。""" | |
| if not isinstance(tool, dict): | |
| return [] | |
| tool_type = str(tool.get("type") or "").lower() | |
| if tool_type in _OPENAI_WEB_SEARCH_TOOL_TYPES: | |
| return [{"googleSearch": {}}] | |
| if tool_type in _OPENAI_CODE_TOOL_TYPES: | |
| return [{"codeExecution": {}}] | |
| if tool_type in _OPENAI_URL_CONTEXT_TOOL_TYPES: | |
| return [{"urlContext": {}}] | |
| if tool_type in _OPENAI_FILE_SEARCH_TOOL_TYPES: | |
| if isinstance(tool.get("retrieval"), dict) and tool["retrieval"]: | |
| return [{"retrieval": copy.deepcopy(tool["retrieval"])}] | |
| return [{"functionDeclarations": [_fallback_openai_tool_declaration(tool, tool_type)]}] | |
| if tool_type in _OPENAI_FALLBACK_TOOL_TYPES or tool_type: | |
| return [{"functionDeclarations": [_fallback_openai_tool_declaration(tool, tool_type)]}] | |
| return [] | |
| def _fallback_openai_tool_declaration(tool: dict[str, Any], tool_type: str) -> dict[str, Any]: | |
| """把 Gemini 不支持的 OpenAI 工具安全降级为普通函数声明。""" | |
| raw_name = tool.get("name") or tool.get("function") or tool_type or "tool" | |
| name = _sanitize_gemini_function_name(str(raw_name)) | |
| description = tool.get("description") or f"OpenAI-compatible tool `{tool_type or name}`." | |
| parameters = ( | |
| tool.get("parameters") | |
| or tool.get("input_schema") | |
| or tool.get("inputSchema") | |
| or tool.get("schema") | |
| or {"type": "object", "properties": {}} | |
| ) | |
| decl: dict[str, Any] = { | |
| "name": name, | |
| "description": str(description), | |
| "parameters": _ensure_object_schema(_sanitize_schema_for_gemini(parameters)) if isinstance(parameters, dict) else {"type": "object", "properties": {}}, | |
| } | |
| return decl | |
| def _ensure_synthetic_function_tool(gemini_payload: dict[str, Any], fn_name: str) -> None: | |
| """tool_choice 指向未声明函数时补一个最小函数声明,避免上游 allowedFunctionNames 报错。""" | |
| tools = gemini_payload.setdefault("tools", []) | |
| if not isinstance(tools, list): | |
| gemini_payload["tools"] = tools = [] | |
| for tool in tools: | |
| if isinstance(tool, dict) and isinstance(tool.get("functionDeclarations"), list): | |
| declarations = tool["functionDeclarations"] | |
| if any(isinstance(decl, dict) and decl.get("name") == fn_name for decl in declarations): | |
| return | |
| declarations.append({"name": fn_name, "parameters": {"type": "object", "properties": {}}}) | |
| return | |
| tools.insert(0, {"functionDeclarations": [{"name": fn_name, "parameters": {"type": "object", "properties": {}}}]}) | |
| def _sanitize_gemini_function_name(name: str) -> str: | |
| cleaned = _GEMINI_FUNCTION_NAME_RE.sub("_", name.strip())[:64].strip("._-") | |
| if cleaned and not (cleaned[0].isalpha() or cleaned[0] == "_"): | |
| cleaned = f"tool_{cleaned}"[:64] | |
| return cleaned or "tool" | |
| def _ensure_object_schema(schema: Any) -> dict[str, Any]: | |
| if not isinstance(schema, dict): | |
| return {"type": "object", "properties": {}} | |
| ensured = copy.deepcopy(schema) | |
| if not ensured.get("type"): | |
| ensured["type"] = "object" | |
| if str(ensured.get("type")).lower() == "object" and not isinstance(ensured.get("properties"), dict): | |
| ensured["properties"] = {} | |
| return ensured | |
| def _sanitize_schema_for_gemini(schema: Any) -> Any: | |
| """清理 OpenAI/JSON Schema 中 Gemini 工具声明不稳定支持的字段。""" | |
| if isinstance(schema, list): | |
| return [_sanitize_schema_for_gemini(item) for item in schema] | |
| if not isinstance(schema, dict): | |
| return schema | |
| unsupported_keys = { | |
| "$schema", "$id", "$defs", "definitions", "additionalProperties", | |
| "patternProperties", "unevaluatedProperties", "dependentSchemas", | |
| "if", "then", "else", "not", | |
| "examples", "default", "nullable", | |
| } | |
| sanitized: dict[str, Any] = {} | |
| for key, value in schema.items(): | |
| if key in unsupported_keys: | |
| continue | |
| if key in {"allOf", "anyOf", "oneOf"} and isinstance(value, list): | |
| flattened = _flatten_composed_schema(key, value) | |
| for flattened_key, flattened_value in flattened.items(): | |
| sanitized[flattened_key] = _sanitize_schema_for_gemini(flattened_value) | |
| continue | |
| if key == "const": | |
| sanitized["enum"] = [value] | |
| continue | |
| if key == "type" and isinstance(value, list): | |
| non_null_types = [item for item in value if item != "null"] | |
| sanitized[key] = non_null_types[0] if non_null_types else "string" | |
| continue | |
| sanitized[key] = _sanitize_schema_for_gemini(value) | |
| if sanitized.get("type") == "object" and "properties" not in sanitized: | |
| sanitized["properties"] = {} | |
| return sanitized | |
| def _flatten_composed_schema(kind: str, variants: list[Any]) -> dict[str, Any]: | |
| """把 OpenAI 常见组合 schema 尽量降级成 Gemini 可接受的 schema。""" | |
| dict_variants = [copy.deepcopy(v) for v in variants if isinstance(v, dict)] | |
| if not dict_variants: | |
| return {} | |
| if kind == "allOf": | |
| merged: dict[str, Any] = {} | |
| required: list[Any] = [] | |
| properties: dict[str, Any] = {} | |
| for variant in dict_variants: | |
| for key, value in variant.items(): | |
| if key == "properties" and isinstance(value, dict): | |
| properties.update(value) | |
| elif key == "required" and isinstance(value, list): | |
| required.extend(item for item in value if item not in required) | |
| else: | |
| merged[key] = value | |
| if properties: | |
| merged["properties"] = properties | |
| merged.setdefault("type", "object") | |
| if required: | |
| merged["required"] = required | |
| return merged | |
| first = dict_variants[0] | |
| if all(v.get("type") == "object" for v in dict_variants): | |
| properties = {} | |
| for variant in dict_variants: | |
| if isinstance(variant.get("properties"), dict): | |
| properties.update(variant["properties"]) | |
| if properties: | |
| return {"type": "object", "properties": properties} | |
| return first | |
| def _parse_data_uri(uri: str) -> tuple[str, str]: | |
| """解析 data:mime;base64,DATA 格式""" | |
| try: | |
| header, data = uri.split(",", 1) | |
| mime = header.split(":")[1].split(";")[0] | |
| return mime, data | |
| except (ValueError, IndexError): | |
| return "", "" | |
| def _image_url_to_part(url: str) -> dict[str, Any] | None: | |
| if not url: | |
| return None | |
| if url.startswith("data:"): | |
| mime, b64 = _parse_data_uri(url) | |
| if mime and b64: | |
| return {"inlineData": {"mimeType": mime, "data": _normalize_base64(b64)}} | |
| return None | |
| if url.startswith(("http://", "https://", "gs://")): | |
| return {"fileData": {"mimeType": _guess_mime_from_url(url), "fileUri": url}} | |
| return None | |
| def _parse_gemini_sse(chunk: str) -> dict[str, Any] | None: | |
| """从 Gemini SSE 行解析 JSON""" | |
| s = chunk.strip() | |
| if s.startswith("data: "): | |
| s = s[6:] | |
| if not s: | |
| return None | |
| try: | |
| return json.loads(s) | |
| except json.JSONDecodeError: | |
| return None | |
| def _extract_parts(parts: list[dict[str, Any]], for_stream: bool = False) -> tuple[str, list[dict[str, Any]] | None, str]: | |
| """从 Gemini parts 提取 (text_content, tool_calls, reasoning_content)""" | |
| texts: list[str] = [] | |
| thoughts: list[str] = [] | |
| tool_calls: list[dict[str, Any]] = [] | |
| for i, part in enumerate(parts): | |
| if part.get("thought") and "text" in part: | |
| thoughts.append(str(part["text"])) | |
| elif "text" in part and not part.get("thought"): | |
| texts.append(str(part["text"])) | |
| if "executableCode" in part or "executable_code" in part: | |
| code_obj = part.get("executableCode") or part.get("executable_code") | |
| if isinstance(code_obj, dict) and "code" in code_obj: | |
| lang = code_obj.get("language", "python") | |
| texts.append(f"\n```{lang}\n{code_obj['code']}\n```\n") | |
| if "codeExecutionResult" in part or "code_execution_result" in part: | |
| res_obj = part.get("codeExecutionResult") or part.get("code_execution_result") | |
| if isinstance(res_obj, dict) and "output" in res_obj: | |
| texts.append(f"\n```output\n{res_obj['output']}\n```\n") | |
| if "functionCall" in part or "function_call" in part: | |
| fc = part.get("functionCall") or part.get("function_call") or {} | |
| if not isinstance(fc, dict): | |
| continue | |
| args = _coerce_function_args(fc.get("args", {})) | |
| thought_signature = part.get("thoughtSignature") or part.get("thought_signature") | |
| call_id = fc.get("id") or fc.get("callId") or fc.get("call_id") or part.get("id") or part.get("callId") or part.get("call_id") | |
| tool_call: dict[str, Any] = { | |
| "index": len(tool_calls), | |
| "id": str(call_id) if call_id else f"call_{uuid.uuid4().hex[:24]}", | |
| "type": "function", | |
| "function": { | |
| "name": fc.get("name", ""), | |
| "arguments": json.dumps(args, ensure_ascii=False), | |
| }, | |
| } | |
| if thought_signature: | |
| # 非标准扩展字段:OpenAI 协议本身没有 thoughtSignature, | |
| # 但部分代理客户端会原样回传 unknown fields,可用于 Gemini 3 工具调用续写。 | |
| tool_call["thought_signature"] = thought_signature | |
| tool_call["function"]["thought_signature"] = thought_signature | |
| if not for_stream: | |
| tool_call.pop("index", None) | |
| tool_calls.append(tool_call) | |
| text_content = "".join(texts) | |
| reasoning = "".join(thoughts) | |
| return text_content, tool_calls if tool_calls else None, reasoning | |
| def _map_finish_reason(finish: Any, has_tool_calls: bool = False) -> str: | |
| """Gemini finishReason → OpenAI finish_reason""" | |
| if has_tool_calls: | |
| return "tool_calls" | |
| if not finish: | |
| return "stop" | |
| return FINISH_REASON_MAP.get(str(finish).upper(), "stop") | |
| def _convert_usage(meta: dict[str, Any]) -> dict[str, int]: | |
| """Gemini usageMetadata → OAI usage""" | |
| prompt = meta.get("promptTokenCount", 0) | |
| completion = meta.get("candidatesTokenCount", 0) | |
| return { | |
| "prompt_tokens": prompt, | |
| "completion_tokens": completion, | |
| "total_tokens": meta.get("totalTokenCount", prompt + completion), | |
| } | |
| def _sse_line(obj: dict[str, Any]) -> str: | |
| return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n" | |
| def _convert_modality(value: Any) -> str | None: | |
| normalized = str(value).lower() | |
| if normalized in {"text", "message"}: | |
| return "TEXT" | |
| if normalized in {"image", "images"}: | |
| return "IMAGE" | |
| return None | |
| def _looks_like_image_model(model: str) -> bool: | |
| model_l = model.lower() | |
| return "image" in model_l or model_l in OPENAI_IMAGE_MODEL_ALIASES | |
| def _coerce_positive_int(value: Any, default: int, maximum: int) -> int: | |
| try: | |
| parsed = int(value) | |
| except (TypeError, ValueError): | |
| parsed = default | |
| return max(1, min(parsed, maximum)) | |
| def _normalize_base64(data: str) -> str: | |
| value = data.strip() | |
| if "," in value and value.startswith("data:"): | |
| _, value = value.split(",", 1) | |
| value = value.replace("-", "+").replace("_", "/") | |
| padding = len(value) % 4 | |
| if padding: | |
| value += "=" * (4 - padding) | |
| return value | |
| def _guess_mime_from_url(url: str) -> str: | |
| lower = url.lower().split("?", 1)[0].split("#", 1)[0] | |
| if lower.endswith(".jpg") or lower.endswith(".jpeg"): | |
| return "image/jpeg" | |
| if lower.endswith(".webp"): | |
| return "image/webp" | |
| if lower.endswith(".gif"): | |
| return "image/gif" | |
| return "image/png" | |
| def _output_format_to_mime(output_format: Any) -> str | None: | |
| if not output_format: | |
| return None | |
| value = str(output_format).lower().strip() | |
| if value in {"png", "image/png"}: | |
| return "image/png" | |
| if value in {"jpeg", "jpg", "image/jpeg"}: | |
| return "image/jpeg" | |
| if value in {"webp", "image/webp"}: | |
| return "image/webp" | |
| return None | |
| def _build_image_prompt( | |
| prompt: str, | |
| size: Any, | |
| quality: Any, | |
| style: Any, | |
| background: Any, | |
| mode: str, | |
| has_mask: bool, | |
| ) -> str: | |
| lines = [prompt.strip()] | |
| if mode == "edit": | |
| lines.append("Edit the provided image according to the prompt while preserving unaffected details.") | |
| elif mode == "variation": | |
| lines.append("Create a faithful variation of the provided image.") | |
| if has_mask: | |
| lines.append("Respect the provided mask as the editable region.") | |
| if size and str(size).lower() != "auto": | |
| lines.append(f"Target output size/aspect: {size}.") | |
| if quality and str(quality).lower() != "auto": | |
| lines.append(f"Quality preference: {quality}.") | |
| if style and str(style).lower() != "auto": | |
| lines.append(f"Style preference: {style}.") | |
| if background and str(background).lower() != "auto": | |
| lines.append(f"Background preference: {background}.") | |
| return "\n".join(line for line in lines if line) | |
| def _append_negative_prompt(prompt: str, negative_prompt: Any) -> str: | |
| if negative_prompt is None or str(negative_prompt).strip() == "": | |
| return prompt | |
| return f"{prompt.strip()}\nAvoid: {negative_prompt}".strip() | |
| def _size_to_aspect_ratio(size: Any) -> str | None: | |
| if not size: | |
| return None | |
| value = str(size).lower().strip() | |
| if value in {"auto", ""}: | |
| return None | |
| if value in {"1024x1024", "1536x1536"}: | |
| return "1:1" | |
| if value in {"1536x1024", "1792x1024"}: | |
| return "3:2" if value.startswith("1536") else "16:9" | |
| if value in {"1024x1536", "1024x1792"}: | |
| return "2:3" if value.endswith("1536") else "9:16" | |
| try: | |
| width_str, height_str = value.split("x", 1) | |
| width = int(width_str) | |
| height = int(height_str) | |
| if width <= 0 or height <= 0: | |
| return None | |
| gcd = math.gcd(width, height) | |
| ratio = f"{width // gcd}:{height // gcd}" | |
| supported = {"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"} | |
| return ratio if ratio in supported else None | |
| except (ValueError, TypeError): | |
| return None | |
| def _size_to_image_size(size: Any) -> str | None: | |
| if not size: | |
| return None | |
| value = str(size).lower().strip() | |
| try: | |
| width_str, height_str = value.split("x", 1) | |
| max_side = max(int(width_str), int(height_str)) | |
| except (ValueError, TypeError): | |
| return None | |
| if max_side >= 3000: | |
| return "4K" | |
| if max_side >= 1500: | |
| return "2K" | |
| return "1K" | |
| def _iter_response_parts(gemini_response: dict[str, Any]) -> list[dict[str, Any]]: | |
| parts: list[dict[str, Any]] = [] | |
| for candidate in gemini_response.get("candidates", []) or []: | |
| if not isinstance(candidate, dict): | |
| continue | |
| content = candidate.get("content") or {} | |
| if not isinstance(content, dict): | |
| continue | |
| for part in content.get("parts", []) or []: | |
| if isinstance(part, dict): | |
| parts.append(part) | |
| return parts | |
| def _image_to_oai_chat_image(image: dict[str, str]) -> dict[str, str]: | |
| item = {"type": "image", "mime_type": image.get("mime_type", "image/png")} | |
| if image.get("kind") == "file": | |
| item["url"] = image["url"] | |
| else: | |
| item["b64_json"] = image["data"] | |
| return item | |
| def _extract_image_from_part(part: dict[str, Any]) -> dict[str, str] | None: | |
| inline_data = part.get("inlineData") or part.get("inline_data") | |
| if isinstance(inline_data, dict): | |
| mime_type = inline_data.get("mimeType") or inline_data.get("mime_type") or "image/png" | |
| data = inline_data.get("data") | |
| if isinstance(data, str) and data.strip(): | |
| return {"kind": "inline", "mime_type": str(mime_type), "data": _normalize_base64(data)} | |
| file_data = part.get("fileData") or part.get("file_data") | |
| if isinstance(file_data, dict): | |
| mime_type = file_data.get("mimeType") or file_data.get("mime_type") or "image/png" | |
| file_uri = file_data.get("fileUri") or file_data.get("file_uri") or file_data.get("uri") or file_data.get("url") | |
| if isinstance(file_uri, str) and file_uri.strip(): | |
| return {"kind": "file", "mime_type": str(mime_type), "url": file_uri.strip()} | |
| return None | |
| def _coerce_function_args(args: Any) -> Any: | |
| if isinstance(args, str): | |
| try: | |
| return json.loads(args) | |
| except json.JSONDecodeError: | |
| return {"raw": args} | |
| return args if args is not None else {} | |