From fe63280b52d4e4890d370a217ee755abfbdbf98e Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Thu, 29 Oct 2026 14:30:10 -0400 Subject: [PATCH] Strip API version from configured base URLs Base URLs for OpenAI and Anthropic backends no longer store the API version suffix. The version is now owned by the backend, which exposes an `api_version` attribute and an `api_base_url` property that joins the base URL with the version when building requests. This keeps stored configuration version agnostic and makes it possible to support other API versions (or version discovery) without reconfiguring backends. Added `strip_api_version`, `normalize_backend_base_url` and `normalize_global_config_base_urls` helpers in the common config module. Loading the global configuration now migrates legacy versioned base URLs in place and persists the normalized values back to disk. The interactive config editor and the backend creation prompt also strip any version suffix the user enters. Tests updated and extended to cover the new helpers and the split between `base_url` and `api_base_url`. Assisted-by: claude-opus-5 Signed-off-by: Simo Sorce --- .../common/backends/anthropic_backend.py | 34 ++++++-- .../vimini/common/backends/proxy_backend.py | 31 ++++++- python3/vimini/common/config.py | 82 ++++++++++++++++++- python3/vimini/config.py | 9 +- tests/test_anthropic_backend.py | 10 ++- tests/test_global_config.py | 44 ++++++++++ tests/test_proxy_backend.py | 7 +- 7 files changed, 199 insertions(+), 18 deletions(-) diff --git a/python3/vimini/common/backends/anthropic_backend.py b/python3/vimini/common/backends/anthropic_backend.py index 02567a4..9642870 100644 --- a/python3/vimini/common/backends/anthropic_backend.py +++ b/python3/vimini/common/backends/anthropic_backend.py @@ -11,10 +11,16 @@ ToolCall, ToolResponse, ) -from vimini.common.config import load_backend_api_key +from vimini.common.config import ( + DEFAULT_API_VERSION, + load_backend_api_key, + strip_api_version, +) logger = logging.getLogger("vimini_agent") +DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com" + def convert_schema_to_anthropic(schema: Any) -> Dict[str, Any]: """ @@ -395,20 +401,34 @@ def __init__( api_key = load_backend_api_key(config) self.api_key = api_key - url = base_url or self.config.get("base_url") or "https://api.anthropic.com/v1" - url = url.rstrip("/") - if not url.endswith("/v1") and "anthropic.com" in url: - url = f"{url}/v1" - self.base_url = url + self.api_version = ( + str(self.config.get("api_version") or DEFAULT_API_VERSION) + .strip() + .strip("/") + ) + + url = base_url or self.config.get("base_url") or DEFAULT_ANTHROPIC_BASE_URL + # The API version is owned by the backend, not the configuration, so + # strip it if a legacy versioned base_url is supplied. + self.base_url = strip_api_version(url, api_version=self.api_version) self.headers = dict(headers or self.config.get("headers", {})) self.anthropic_version = self.config.get("anthropic_version") or "2023-06-01" self.max_tokens = int(self.config.get("max_tokens") or 4096) + @property + def api_base_url(self) -> str: + """ + Returns the versioned base URL used for API requests. + """ + if not self.api_version: + return self.base_url + return f"{self.base_url}/{self.api_version}" + def _make_request( self, endpoint: str, data: Optional[Dict[str, Any]] = None, stream: bool = False ): - url = f"{self.base_url}{endpoint}" + url = f"{self.api_base_url}{endpoint}" req_headers = { "Content-Type": "application/json", "anthropic-version": self.anthropic_version, diff --git a/python3/vimini/common/backends/proxy_backend.py b/python3/vimini/common/backends/proxy_backend.py index d83cceb..edf1810 100644 --- a/python3/vimini/common/backends/proxy_backend.py +++ b/python3/vimini/common/backends/proxy_backend.py @@ -11,10 +11,16 @@ ToolCall, ToolResponse, ) -from vimini.common.config import load_backend_api_key +from vimini.common.config import ( + DEFAULT_API_VERSION, + load_backend_api_key, + strip_api_version, +) logger = logging.getLogger("vimini_agent") +DEFAULT_OPENAI_BASE_URL = "https://api.openai.com" + def convert_schema_to_openai(schema: Any) -> Dict[str, Any]: """ @@ -342,14 +348,31 @@ def __init__( api_key = load_backend_api_key(config) self.api_key = api_key - url = base_url or self.config.get("base_url") or "https://api.openai.com/v1" - self.base_url = url.rstrip("/") + self.api_version = ( + str(self.config.get("api_version") or DEFAULT_API_VERSION) + .strip() + .strip("/") + ) + + url = base_url or self.config.get("base_url") or DEFAULT_OPENAI_BASE_URL + # The API version is owned by the backend, not the configuration, so + # strip it if a legacy versioned base_url is supplied. + self.base_url = strip_api_version(url, api_version=self.api_version) self.headers = dict(headers or self.config.get("headers", {})) + @property + def api_base_url(self) -> str: + """ + Returns the versioned base URL used for API requests. + """ + if not self.api_version: + return self.base_url + return f"{self.base_url}/{self.api_version}" + def _make_request( self, endpoint: str, data: Optional[Dict[str, Any]] = None, stream: bool = False ): - url = f"{self.base_url}{endpoint}" + url = f"{self.api_base_url}{endpoint}" req_headers = { "Content-Type": "application/json", "User-Agent": "Vimini/ProxyAPI", diff --git a/python3/vimini/common/config.py b/python3/vimini/common/config.py index cb00495..23a5df6 100644 --- a/python3/vimini/common/config.py +++ b/python3/vimini/common/config.py @@ -12,6 +12,8 @@ DEFAULT_ANTHROPIC_KEY_FILE = "~/.config/anthropic.token" DEFAULT_ANTHROPIC_MODEL = "claude-3-7-sonnet-20250219" SUPPORTED_API_TYPES = {"genai", "openai", "anthropic"} +VERSIONED_API_TYPES = ("openai", "anthropic") +DEFAULT_API_VERSION = "v1" DEFAULT_UI_CONFIG = { "thinking_color": "DarkGray", @@ -58,6 +60,72 @@ def get_global_config_path(): return DEFAULT_CONFIG_PATH +def strip_api_version(base_url, api_version=DEFAULT_API_VERSION): + """ + Removes a trailing API version segment (e.g. '/v1') from a base URL. + + Base URLs are stored and handled without the API version so that the + version is applied by the backend itself, allowing future support for + multiple API versions (and version discovery) without reconfiguration. + """ + if not isinstance(base_url, str): + return base_url + + url = base_url.strip().rstrip("/") + if not url: + return "" + + version = str(api_version or "").strip().strip("/") + if version: + suffix = f"/{version}" + if url.endswith(suffix): + url = url[: -len(suffix)].rstrip("/") + return url + + +def normalize_backend_base_url(backend_config, api_version=DEFAULT_API_VERSION): + """ + Normalizes a single backend 'base_url' in place by stripping the API + version suffix. Returns True if the configuration was modified. + """ + if not isinstance(backend_config, dict): + return False + + api = str(backend_config.get("api", "")).strip() + if api not in VERSIONED_API_TYPES: + return False + + base_url = backend_config.get("base_url") + if not isinstance(base_url, str) or not base_url.strip(): + return False + + normalized = strip_api_version(base_url, api_version=api_version) + if normalized != base_url: + backend_config["base_url"] = normalized + return True + return False + + +def normalize_global_config_base_urls(config): + """ + Normalizes every OpenAI/Anthropic backend 'base_url' in a global + configuration by stripping any legacy API version suffix. Returns True if + any backend configuration was modified. + """ + if not isinstance(config, dict): + return False + + backends = config.get("backends") + if not isinstance(backends, dict): + return False + + changed = False + for backend_config in backends.values(): + if normalize_backend_base_url(backend_config): + changed = True + return changed + + def create_default_global_config(default_model=None, api_key_file=None): """ Generates a default v1 configuration dictionary. @@ -172,7 +240,19 @@ def load_global_config(config_path=None, default_model=None, api_key_file=None): f"Failed to parse global configuration file '{target_path}': {e}" ) - return validate_global_config(data) + config = validate_global_config(data) + + # Migrate legacy base_url values that still embed the API version so the + # stored configuration remains version agnostic. + if normalize_global_config_base_urls(config): + try: + save_global_config(config, config_path=target_path) + except Exception as e: + logger.warning( + f"Failed to persist normalized base_url values to '{target_path}': {e}" + ) + + return config def save_global_config(config, config_path=None): diff --git a/python3/vimini/config.py b/python3/vimini/config.py index 600de09..8080629 100644 --- a/python3/vimini/config.py +++ b/python3/vimini/config.py @@ -16,6 +16,7 @@ get_global_config_path, load_global_config, save_global_config, + strip_api_version, validate_global_config, ) @@ -397,6 +398,10 @@ def edit_global_config_option(): return backend_cfg.pop(field, None) else: + if field == "base_url": + # The API version is applied by the backend, so it is never + # stored as part of the configured base URL. + new_val = strip_api_version(new_val) backend_cfg[field] = new_val _refresh_global_config_buffer(win, line_num, col) util.display_message(f"Updated '{backend_name}.{field}'.") @@ -453,7 +458,9 @@ def add_global_backend(): if default_model: backend_cfg["default_model"] = default_model if api in ("openai", "anthropic"): - base_url = _to_str(vim.eval("input('Base URL: ')")).strip() + base_url = strip_api_version( + _to_str(vim.eval("input('Base URL (no API version suffix): ')")).strip() + ) if base_url: backend_cfg["base_url"] = base_url backends[name] = backend_cfg diff --git a/tests/test_anthropic_backend.py b/tests/test_anthropic_backend.py index 952c184..34aa09e 100644 --- a/tests/test_anthropic_backend.py +++ b/tests/test_anthropic_backend.py @@ -112,7 +112,9 @@ def test_anthropic_backend_init(): }, api_key="sk-ant-test", ) - assert backend.base_url == "https://api.anthropic.com/v1" + assert backend.base_url == "https://api.anthropic.com" + assert backend.api_version == "v1" + assert backend.api_base_url == "https://api.anthropic.com/v1" assert backend.api_key == "sk-ant-test" assert backend.headers == {"X-Custom": "custom-val"} assert backend.max_tokens == 8192 @@ -493,7 +495,8 @@ def test_get_backend_factory_resolves_anthropic_backend(): ): backend = get_backend("Claude", config=config) assert isinstance(backend, AnthropicBackend) - assert backend.base_url == "https://api.anthropic.com/v1" + assert backend.base_url == "https://api.anthropic.com" + assert backend.api_base_url == "https://api.anthropic.com/v1" assert backend.api_key == "fake-ant-key" @@ -530,4 +533,5 @@ def test_anthropic_backend_keyless_allowed(): backend = get_backend("LocalClaude", config=config) assert isinstance(backend, AnthropicBackend) assert backend.api_key is None - assert backend.base_url == "http://localhost:8000/v1" + assert backend.base_url == "http://localhost:8000" + assert backend.api_base_url == "http://localhost:8000/v1" diff --git a/tests/test_global_config.py b/tests/test_global_config.py index d461d86..1618f4d 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -14,6 +14,8 @@ DEFAULT_GEMINI_KEY_FILE, DEFAULT_UI_CONFIG, create_default_global_config, + normalize_global_config_base_urls, + strip_api_version, validate_global_config, load_global_config, save_global_config, @@ -83,6 +85,48 @@ def test_load_global_config_reads_existing(tmp_path): cfg = load_global_config(config_path=str(config_file)) assert cfg["active_backend"] == "Custom 1" assert "Custom 1" in cfg["backends"] + # Legacy '/v1' suffixes are stripped from stored base URLs. + assert cfg["backends"]["Custom 1"]["base_url"] == "https://api.proxy.com" + + # The normalized configuration is written back to disk. + with open(config_file, "r", encoding="utf-8") as f: + disk_data = json.load(f) + assert disk_data["backends"]["Custom 1"]["base_url"] == "https://api.proxy.com" + + +def test_strip_api_version(): + assert strip_api_version("https://api.openai.com/v1") == "https://api.openai.com" + assert strip_api_version("https://api.openai.com/v1/") == "https://api.openai.com" + assert strip_api_version("http://localhost:11434") == "http://localhost:11434" + assert strip_api_version("http://localhost:11434/") == "http://localhost:11434" + assert strip_api_version("https://host/api/v1") == "https://host/api" + # Only the configured version suffix is stripped. + assert strip_api_version("https://host/v1beta") == "https://host/v1beta" + assert strip_api_version("") == "" + assert strip_api_version(None) is None + + +def test_normalize_global_config_base_urls(): + config = { + "active_backend": "Proxy", + "backends": { + "Gemini": {"api": "genai", "base_url": "https://genai.example/v1"}, + "Proxy": {"api": "openai", "base_url": "https://proxy.example/v1"}, + "Claude": {"api": "anthropic", "base_url": "https://claude.example/v1/"}, + "Local": {"api": "openai", "base_url": "http://localhost:11434"}, + }, + } + + assert normalize_global_config_base_urls(config) is True + backends = config["backends"] + assert backends["Proxy"]["base_url"] == "https://proxy.example" + assert backends["Claude"]["base_url"] == "https://claude.example" + assert backends["Local"]["base_url"] == "http://localhost:11434" + # Non-versioned APIs are left untouched. + assert backends["Gemini"]["base_url"] == "https://genai.example/v1" + + # Already normalized configurations report no changes. + assert normalize_global_config_base_urls(config) is False def test_load_global_config_invalid_json(tmp_path): diff --git a/tests/test_proxy_backend.py b/tests/test_proxy_backend.py index 456e858..1a90cf3 100644 --- a/tests/test_proxy_backend.py +++ b/tests/test_proxy_backend.py @@ -95,7 +95,9 @@ def test_proxy_backend_init(): }, api_key="my-key", ) - assert backend.base_url == "https://custom.api/v1" + assert backend.base_url == "https://custom.api" + assert backend.api_version == "v1" + assert backend.api_base_url == "https://custom.api/v1" assert backend.api_key == "my-key" assert backend.headers == {"X-Test": "123"} @@ -607,4 +609,5 @@ def test_get_backend_factory_resolves_proxy_backend(): with patch("vimini.common.backends.load_backend_api_key", return_value="fake-key"): backend = get_backend("Custom 1", config=config) assert isinstance(backend, ProxyBackend) - assert backend.base_url == "https://api.proxy.com/v1" + assert backend.base_url == "https://api.proxy.com" + assert backend.api_base_url == "https://api.proxy.com/v1"