Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions python3/vimini/common/backends/anthropic_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 27 additions & 4 deletions python3/vimini/common/backends/proxy_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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",
Expand Down
82 changes: 81 additions & 1 deletion python3/vimini/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion python3/vimini/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
get_global_config_path,
load_global_config,
save_global_config,
strip_api_version,
validate_global_config,
)

Expand Down Expand Up @@ -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}'.")
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions tests/test_anthropic_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"


Expand Down Expand Up @@ -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"
44 changes: 44 additions & 0 deletions tests/test_global_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
7 changes: 5 additions & 2 deletions tests/test_proxy_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down Expand Up @@ -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"
Loading