From d6218d6badbf56a361e4a4932995e7ffb08c40d7 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Thu, 2 Apr 2026 10:25:18 +0800 Subject: [PATCH] feat(gemini): normalize base URL to strip API version suffixes Automatically remove trailing slashes and version paths (e.g., /v1beta) from GEMINI_BASE_URL Update GeminiAnalyzer to use the normalized URL and add type hints Add test coverage for Gemini client configuration --- .codex | 0 .gitignore | 2 + README.md | 1 + src/uipath_explainator/config.py | 8 +++ src/uipath_explainator/gemini.py | 10 ++-- tests/test_gemini.py | 86 ++++++++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 .codex create mode 100644 tests/test_gemini.py diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index e15106e..7516d00 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,5 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +workspace \ No newline at end of file diff --git a/README.md b/README.md index 1db6230..4434434 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ GEMINI_MODEL=gemini-2.5-flash ``` `GEMINI_BASE_URL` 留空时走官方默认地址;如果你前面挂了代理或网关,可以填自定义地址。 +这里填写网关根地址即可,例如 `https://your-gateway.example.com`,不要自己追加 `/v1beta`、`/v1alpha` 或 `/v1`。 ## 使用 diff --git a/src/uipath_explainator/config.py b/src/uipath_explainator/config.py index 58cdfa2..c24f583 100644 --- a/src/uipath_explainator/config.py +++ b/src/uipath_explainator/config.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path +import re from typing import Self from dotenv import load_dotenv @@ -30,3 +31,10 @@ class Settings: def require_api_key(self) -> None: if not self.api_key: raise ValueError("Missing GEMINI_API_KEY (or GOOGLE_API_KEY) in the environment.") + + def normalized_base_url(self) -> str | None: + if not self.base_url: + return None + + base_url = self.base_url.strip().rstrip("/") + return re.sub(r"/v\d+(?:alpha|beta)?$", "", base_url, flags=re.IGNORECASE) diff --git a/src/uipath_explainator/gemini.py b/src/uipath_explainator/gemini.py index e1228b6..a9b12e0 100644 --- a/src/uipath_explainator/gemini.py +++ b/src/uipath_explainator/gemini.py @@ -1,7 +1,8 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path +from typing import Any import json from .config import Settings @@ -10,6 +11,8 @@ from .config import Settings @dataclass(slots=True) class GeminiAnalyzer: settings: Settings + _types: Any = field(init=False, repr=False) + _client: Any = field(init=False, repr=False) def __post_init__(self) -> None: self.settings.require_api_key() @@ -18,8 +21,9 @@ class GeminiAnalyzer: from google.genai import types http_options = types.HttpOptions(timeout=120_000) - if self.settings.base_url: - http_options = types.HttpOptions(base_url=self.settings.base_url, timeout=120_000) + base_url = self.settings.normalized_base_url() + if base_url: + http_options = types.HttpOptions(base_url=base_url, timeout=120_000) self._types = types self._client = genai.Client(api_key=self.settings.api_key, http_options=http_options) diff --git a/tests/test_gemini.py b/tests/test_gemini.py new file mode 100644 index 0000000..787af8a --- /dev/null +++ b/tests/test_gemini.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from pathlib import Path +from types import ModuleType, SimpleNamespace +import sys +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +fake_dotenv = ModuleType("dotenv") +fake_dotenv.load_dotenv = lambda *args, **kwargs: None +sys.modules.setdefault("dotenv", fake_dotenv) + +from uipath_explainator.config import Settings +from uipath_explainator.gemini import GeminiAnalyzer + + +class FakeHttpOptions: + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + + +class FakeGenerateContentConfig: + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + + +class FakeClient: + def __init__(self, api_key: str, http_options: FakeHttpOptions) -> None: + self.api_key = api_key + self.http_options = http_options + self.models = SimpleNamespace() + + +class GeminiAnalyzerTests(unittest.TestCase): + def test_init_with_slots_declares_runtime_fields(self) -> None: + fake_types = SimpleNamespace( + HttpOptions=FakeHttpOptions, + GenerateContentConfig=FakeGenerateContentConfig, + ) + fake_genai = ModuleType("google.genai") + fake_genai.Client = FakeClient + fake_genai.types = fake_types + + fake_google = ModuleType("google") + fake_google.genai = fake_genai + + with patch.dict(sys.modules, {"google": fake_google, "google.genai": fake_genai}): + analyzer = GeminiAnalyzer(Settings(api_key="test-key", base_url=None, model="gemini-test")) + + self.assertIs(analyzer._types, fake_types) + self.assertIsInstance(analyzer._client, FakeClient) + self.assertEqual(analyzer._client.api_key, "test-key") + self.assertEqual(analyzer._client.http_options.kwargs, {"timeout": 120_000}) + + def test_init_strips_version_suffix_from_custom_base_url(self) -> None: + fake_types = SimpleNamespace( + HttpOptions=FakeHttpOptions, + GenerateContentConfig=FakeGenerateContentConfig, + ) + fake_genai = ModuleType("google.genai") + fake_genai.Client = FakeClient + fake_genai.types = fake_types + + fake_google = ModuleType("google") + fake_google.genai = fake_genai + + with patch.dict(sys.modules, {"google": fake_google, "google.genai": fake_genai}): + analyzer = GeminiAnalyzer( + Settings( + api_key="test-key", + base_url="https://newapi.tootaio.com/v1beta/", + model="gemini-test", + ) + ) + + self.assertEqual( + analyzer._client.http_options.kwargs, + {"base_url": "https://newapi.tootaio.com", "timeout": 120_000}, + ) + + +if __name__ == "__main__": + unittest.main()