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"