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
This commit is contained in:
2026-04-02 10:25:18 +08:00
parent 7003dfa0df
commit d6218d6bad
6 changed files with 104 additions and 3 deletions

0
.codex Normal file
View File

2
.gitignore vendored
View File

@@ -214,3 +214,5 @@ __marimo__/
# Streamlit # Streamlit
.streamlit/secrets.toml .streamlit/secrets.toml
workspace

View File

@@ -19,6 +19,7 @@ GEMINI_MODEL=gemini-2.5-flash
``` ```
`GEMINI_BASE_URL` 留空时走官方默认地址;如果你前面挂了代理或网关,可以填自定义地址。 `GEMINI_BASE_URL` 留空时走官方默认地址;如果你前面挂了代理或网关,可以填自定义地址。
这里填写网关根地址即可,例如 `https://your-gateway.example.com`,不要自己追加 `/v1beta``/v1alpha``/v1`
## 使用 ## 使用

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import re
from typing import Self from typing import Self
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -30,3 +31,10 @@ class Settings:
def require_api_key(self) -> None: def require_api_key(self) -> None:
if not self.api_key: if not self.api_key:
raise ValueError("Missing GEMINI_API_KEY (or GOOGLE_API_KEY) in the environment.") 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)

View File

@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any
import json import json
from .config import Settings from .config import Settings
@@ -10,6 +11,8 @@ from .config import Settings
@dataclass(slots=True) @dataclass(slots=True)
class GeminiAnalyzer: class GeminiAnalyzer:
settings: Settings settings: Settings
_types: Any = field(init=False, repr=False)
_client: Any = field(init=False, repr=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.settings.require_api_key() self.settings.require_api_key()
@@ -18,8 +21,9 @@ class GeminiAnalyzer:
from google.genai import types from google.genai import types
http_options = types.HttpOptions(timeout=120_000) http_options = types.HttpOptions(timeout=120_000)
if self.settings.base_url: base_url = self.settings.normalized_base_url()
http_options = types.HttpOptions(base_url=self.settings.base_url, timeout=120_000) if base_url:
http_options = types.HttpOptions(base_url=base_url, timeout=120_000)
self._types = types self._types = types
self._client = genai.Client(api_key=self.settings.api_key, http_options=http_options) self._client = genai.Client(api_key=self.settings.api_key, http_options=http_options)

86
tests/test_gemini.py Normal file
View File

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