From 1d293f9343cbf77d6ecd4afc18efa0a647f59376 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Thu, 29 Oct 2026 17:57:28 -0400 Subject: [PATCH] Use default_backend and harden Anthropic backend Replace the persisted `active_backend` configuration key with `default_backend` so that switching backends at runtime via `:ViminiBackend` does not modify the global configuration file on disk. Cache the global configuration in memory after startup or reload to avoid redundant disk reads. Enhance the Anthropic backend to support `thinking_budget_tokens` and `max_tokens` validation, paginate model listings, preserve `redacted_thinking` and signed `thinking` blocks in chat history, and automatically resolve or synthesize `tool_result` blocks for unanswered `tool_use` turns to satisfy Anthropic API invariants. Assisted-by: Gemini 4 Argon Signed-off-by: Simo Sorce --- README.md | 4 +- python3/vimini/agent/server.py | 7 + python3/vimini/common/backends/__init__.py | 16 +- .../common/backends/anthropic_backend.py | 346 +++++++++++++++--- python3/vimini/common/config.py | 146 ++++++-- python3/vimini/config.py | 96 +++-- python3/vimini/main.py | 49 +-- python3/vimini/models.py | 14 +- python3/vimini/util.py | 16 +- tests/test_agent_server.py | 7 +- tests/test_anthropic_backend.py | 342 ++++++++++++++++- tests/test_backend_abstraction.py | 2 +- tests/test_global_config.py | 224 +++++++++--- tests/test_global_config_ui.py | 108 +++++- tests/test_models.py | 8 +- tests/test_util_buffer.py | 4 +- tests/test_vim_backend_ui.py | 82 +++-- 17 files changed, 1246 insertions(+), 225 deletions(-) diff --git a/README.md b/README.md index f040664..e0c5048 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ customizations. Vimini manages global settings and model provider backends in `~/.var/vimini/config.json`. If this file does not exist, Vimini automatically generates a default configuration on startup: ```json { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": { "api": "genai", @@ -193,7 +193,7 @@ Opens an interactive configuration editor in a new split window to configure glo **How to use the global configuration manager:** * Move the cursor to any configuration option or backend line. -* **`` or `e`**: Edits the selected value. On `active_backend`, enter one of the configured backend names. On a backend header, rename the backend. Under `UI PREFERENCES`, customize colors (e.g. `thinking_color`, `prompt_color`, `waiting_color`, `service_color`, `error_color`, `prompt_hint_color`). +* **`` or `e`**: Edits the selected value. On `default_backend`, enter one of the configured backend names. On a backend header, rename the backend. Under `UI PREFERENCES`, customize colors (e.g. `thinking_color`, `prompt_color`, `waiting_color`, `service_color`, `error_color`, `prompt_hint_color`). * **`a`**: Adds a new backend and prompts for API type, default model, API key file, and base URL for OpenAI-compatible or Anthropic backends. * **`d`**: Clears the selected backend field, deletes a backend after confirmation, or resets a UI preference back to default. The last backend cannot be deleted. * **`r`**: Resets all global configuration options to their last saved state. diff --git a/python3/vimini/agent/server.py b/python3/vimini/agent/server.py index df8c766..7cfae8b 100644 --- a/python3/vimini/agent/server.py +++ b/python3/vimini/agent/server.py @@ -17,6 +17,7 @@ import signal import atexit from vimini.common.backends import get_client +from vimini.common.config import load_global_config MAX_RECEIVE_BUFFER_SIZE = 1024 * 1024 AGENT_CONFIG = {} @@ -60,6 +61,7 @@ def execute_function(req_id, method, params, result_queue, conn): logger.info(f"Executing method: {method} (id: {req_id})") try: if method == "setup": + load_global_config() if isinstance(params, dict): safe_params = dict(params) safe_params.pop("api_key_file", None) @@ -198,6 +200,11 @@ def cleanup(self): def start(self): logger.info(f"Starting AgentServer on socket: {self.socket_path}") + try: + load_global_config() + except Exception as e: + logger.error(f"Failed to load global config on agent startup: {e}") + if os.path.exists(self.socket_path): try: os.unlink(self.socket_path) diff --git a/python3/vimini/common/backends/__init__.py b/python3/vimini/common/backends/__init__.py index ad9ae0d..c0a30c4 100644 --- a/python3/vimini/common/backends/__init__.py +++ b/python3/vimini/common/backends/__init__.py @@ -25,8 +25,9 @@ convert_tools_to_anthropic, ) from vimini.common.config import ( + get_global_config, load_global_config, - get_active_backend, + get_default_backend, load_backend_api_key, validate_global_config, backend_requires_api_key, @@ -41,22 +42,23 @@ def get_backend( api_key_file: Optional[str] = None, ) -> BaseClient: """ - Factory function returning a BaseClient instance for the requested or active backend. + Factory function returning a BaseClient instance for the requested or default backend. Confines all backend selection logic within generic backend methods. """ if isinstance(config, dict) and "backends" in config: validate_global_config(config) global_cfg = config if backend_name is None: - backend_name = ( - config.get("active_backend") or config.get("backend") or "Gemini" - ) + backend_name = config.get("backend") or get_default_backend(global_cfg) else: - global_cfg = load_global_config(config_path=config_path) + if config_path is not None: + global_cfg = load_global_config(config_path=config_path) + else: + global_cfg = get_global_config() if backend_name is None and isinstance(config, dict): backend_name = config.get("backend") if backend_name is None: - backend_name = get_active_backend(global_cfg) + backend_name = get_default_backend(global_cfg) backends = global_cfg.get("backends", {}) if backend_name not in backends: diff --git a/python3/vimini/common/backends/anthropic_backend.py b/python3/vimini/common/backends/anthropic_backend.py index 9642870..7cd5dca 100644 --- a/python3/vimini/common/backends/anthropic_backend.py +++ b/python3/vimini/common/backends/anthropic_backend.py @@ -1,8 +1,9 @@ import json import logging +import urllib.parse import urllib.request import urllib.error -from typing import Any, Dict, Iterator, List, Optional +from typing import Any, Dict, Iterator, List, Optional, Set from vimini.common.backends.base import ( BaseChatSession, @@ -12,8 +13,11 @@ ToolResponse, ) from vimini.common.config import ( + DEFAULT_ANTHROPIC_MAX_TOKENS, DEFAULT_API_VERSION, + MIN_ANTHROPIC_THINKING_BUDGET_TOKENS, load_backend_api_key, + parse_positive_int, strip_api_version, ) @@ -167,11 +171,158 @@ def has_history(self) -> bool: def get_history(self) -> List[Any]: return [] + def _get_pending_tool_use_ids(self) -> List[str]: + """ + Returns any tool_use IDs from the most recent assistant turn that do + not yet have a corresponding tool_result in the following user turn. + """ + if not self.messages: + return [] + + last_msg = self.messages[-1] + if last_msg.get("role") == "assistant": + asst_content = last_msg.get("content") + if not isinstance(asst_content, list): + return [] + return [ + block["id"] + for block in asst_content + if isinstance(block, dict) + and block.get("type") == "tool_use" + and block.get("id") + ] + + if ( + len(self.messages) >= 2 + and last_msg.get("role") == "user" + and self.messages[-2].get("role") == "assistant" + ): + asst_content = self.messages[-2].get("content") + if not isinstance(asst_content, list): + return [] + tool_use_ids = [ + block["id"] + for block in asst_content + if isinstance(block, dict) + and block.get("type") == "tool_use" + and block.get("id") + ] + if not tool_use_ids: + return [] + + user_content = last_msg.get("content") + answered_ids: Set[str] = set() + if isinstance(user_content, list): + answered_ids = { + block["tool_use_id"] + for block in user_content + if isinstance(block, dict) + and block.get("type") == "tool_result" + and block.get("tool_use_id") + } + return [tid for tid in tool_use_ids if tid not in answered_ids] + + return [] + + def _resolve_tool_use_id( + self, + call_id: Optional[str], + index: int = 0, + used_ids: Optional[Set[str]] = None, + ) -> str: + """ + Resolves the tool_use_id for a ToolResponse. Prefers an explicit + call_id, then falls back to the next unanswered tool_use ID in + conversation history, and finally to a deterministic 'toolu_{index}'. + """ + if call_id: + if used_ids is not None: + used_ids.add(call_id) + return call_id + + for pending_id in self._get_pending_tool_use_ids(): + if used_ids is None or pending_id not in used_ids: + if used_ids is not None: + used_ids.add(pending_id) + return pending_id + + fallback_id = f"toolu_{index}" + if used_ids is not None: + used_ids.add(fallback_id) + return fallback_id + + @staticmethod + def _make_unexecuted_tool_result(tool_use_id: str) -> Dict[str, Any]: + return { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": "Tool call not executed.", + "is_error": True, + } + + def _synthesize_pending_tool_results( + self, exclude_ids: Optional[Set[str]] = None + ) -> None: + """ + Synthesizes error tool_result blocks for any unanswered tool_use IDs + so that Anthropic's strict tool_use -> tool_result pairing invariant + is preserved even if a turn was cancelled or only partially answered. + """ + pending_ids = [ + tid + for tid in self._get_pending_tool_use_ids() + if not exclude_ids or tid not in exclude_ids + ] + if not pending_ids: + return + + synthesized = [self._make_unexecuted_tool_result(tid) for tid in pending_ids] + + if self.messages and self.messages[-1].get("role") == "user": + prev = self.messages[-1]["content"] + if isinstance(prev, list): + # Keep all tool_result blocks at the start of the user content list. + tool_results = [ + b + for b in prev + if isinstance(b, dict) and b.get("type") == "tool_result" + ] + other_blocks = [ + b + for b in prev + if not (isinstance(b, dict) and b.get("type") == "tool_result") + ] + self.messages[-1]["content"] = tool_results + synthesized + other_blocks + elif isinstance(prev, str): + self.messages[-1]["content"] = synthesized + [ + {"type": "text", "text": prev} + ] + else: + self.messages.append({"role": "user", "content": synthesized}) + def _append_user_content(self, content: Any) -> None: """ - Appends user content while strictly preserving Anthropic's rule that - messages must alternate between 'user' and 'assistant'. + Appends user content while strictly preserving Anthropic's rules that + messages must alternate between 'user' and 'assistant' and that every + assistant tool_use block must be immediately answered by a tool_result. """ + if isinstance(content, str): + self._synthesize_pending_tool_results() + elif isinstance(content, list): + provided_ids = { + item.get("tool_use_id") + for item in content + if isinstance(item, dict) + and item.get("type") == "tool_result" + and item.get("tool_use_id") + } + has_non_tool_result = any( + not (isinstance(item, dict) and item.get("type") == "tool_result") + for item in content + ) + if has_non_tool_result: + self._synthesize_pending_tool_results(exclude_ids=provided_ids) + if self.messages and self.messages[-1].get("role") == "user": prev = self.messages[-1]["content"] if isinstance(prev, str) and isinstance(content, str): @@ -189,6 +340,16 @@ def _append_user_content(self, content: Any) -> None: else: self.messages.append({"role": "user", "content": content}) + def _append_role_message(self, msg: Dict[str, Any]) -> None: + role = msg.get("role") + content = msg.get("content", "") + if role == "user": + self._append_user_content(content) + return + + self._synthesize_pending_tool_results() + self.messages.append({"role": role, "content": content}) + def send_message_stream( self, prompt_or_tool_responses: Any ) -> Iterator[StreamChunk]: @@ -200,11 +361,14 @@ def send_message_stream( if isinstance(prompt_or_tool_responses.response, str) else json.dumps(prompt_or_tool_responses.response) ) + tool_use_id = self._resolve_tool_use_id( + prompt_or_tool_responses.call_id, index=0 + ) self._append_user_content( [ { "type": "tool_result", - "tool_use_id": prompt_or_tool_responses.call_id or "", + "tool_use_id": tool_use_id, "content": content, } ] @@ -212,48 +376,78 @@ def send_message_stream( elif isinstance(prompt_or_tool_responses, list): tool_results = [] text_items = [] - for item in prompt_or_tool_responses: + used_ids: Set[str] = { + item.call_id + for item in prompt_or_tool_responses + if isinstance(item, ToolResponse) and item.call_id + } + + def flush_pending_user_items() -> None: + nonlocal tool_results, text_items + if tool_results: + self._append_user_content(tool_results) + tool_results = [] + if text_items: + self._append_user_content("\n\n".join(text_items)) + text_items = [] + + for i, item in enumerate(prompt_or_tool_responses): if isinstance(item, ToolResponse): content = ( item.response if isinstance(item.response, str) else json.dumps(item.response) ) + tool_use_id = self._resolve_tool_use_id( + item.call_id, index=i, used_ids=used_ids + ) tool_results.append( { "type": "tool_result", - "tool_use_id": item.call_id or "", + "tool_use_id": tool_use_id, "content": content, } ) elif isinstance(item, dict): if item.get("type") == "tool_result": - tool_results.append(item) + resolved_item = dict(item) + if not resolved_item.get("tool_use_id"): + resolved_item["tool_use_id"] = self._resolve_tool_use_id( + None, index=i, used_ids=used_ids + ) + else: + used_ids.add(resolved_item["tool_use_id"]) + tool_results.append(resolved_item) elif "tool_call_id" in item: + tool_use_id = self._resolve_tool_use_id( + item.get("tool_call_id"), index=i, used_ids=used_ids + ) tool_results.append( { "type": "tool_result", - "tool_use_id": item["tool_call_id"], + "tool_use_id": tool_use_id, "content": str(item.get("content", "")), } ) elif "role" in item: - self.messages.append(item) + flush_pending_user_items() + self._append_role_message(item) else: tool_results.append(item) else: text_items.append(str(item)) - if tool_results: - self._append_user_content(tool_results) - elif text_items: - self._append_user_content("\n\n".join(text_items)) + flush_pending_user_items() elif isinstance(prompt_or_tool_responses, dict): if "role" in prompt_or_tool_responses: - self.messages.append(prompt_or_tool_responses) + self._append_role_message(prompt_or_tool_responses) else: self._append_user_content(json.dumps(prompt_or_tool_responses)) + # Ensure any remaining unanswered tool_use blocks are closed out + # before sending the request. + self._synthesize_pending_tool_results() + payload: Dict[str, Any] = { "model": self.model, "max_tokens": self.backend.max_tokens, @@ -264,6 +458,11 @@ def send_message_stream( payload["system"] = self.system_instruction if self.tools: payload["tools"] = self.tools + if self.backend.thinking_budget_tokens: + payload["thinking"] = { + "type": "enabled", + "budget_tokens": self.backend.thinking_budget_tokens, + } resp = self.backend._make_request("/messages", data=payload, stream=True) @@ -305,10 +504,11 @@ def send_message_stream( ) elif delta_type == "signature_delta": sig = delta.get("signature", "") or "" - if idx in content_blocks: - content_blocks[idx]["signature"] = ( - content_blocks[idx].get("signature", "") + sig - ) + if idx not in content_blocks: + content_blocks[idx] = {"type": "thinking", "thinking": ""} + content_blocks[idx]["signature"] = ( + content_blocks[idx].get("signature", "") + sig + ) elif delta_type == "input_json_delta": partial = delta.get("partial_json", "") or "" if idx not in content_blocks: @@ -357,11 +557,19 @@ def send_message_stream( if txt: asst_content.append({"type": "text", "text": txt}) elif b_type == "thinking": + # Anthropic rejects assistant thinking blocks lacking a non-empty + # signature or non-empty thinking text. thk = block.get("thinking", "") - thk_block = {"type": "thinking", "thinking": thk} - if "signature" in block: - thk_block["signature"] = block["signature"] - asst_content.append(thk_block) + sig = block.get("signature", "") + if thk and sig: + asst_content.append( + {"type": "thinking", "thinking": thk, "signature": sig} + ) + elif b_type == "redacted_thinking": + # Anthropic requires redacted_thinking blocks to be echoed back verbatim. + data = block.get("data", "") + if data: + asst_content.append({"type": "redacted_thinking", "data": data}) elif b_type == "tool_use": call_id = block.get("id") or f"toolu_{idx}" name = block.get("name", "") @@ -414,7 +622,27 @@ def __init__( 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) + + raw_max_tokens = self.config.get("max_tokens") + if raw_max_tokens is None or raw_max_tokens == "": + self.max_tokens = DEFAULT_ANTHROPIC_MAX_TOKENS + else: + self.max_tokens = parse_positive_int(raw_max_tokens, "max_tokens") + + raw_thinking_budget = self.config.get("thinking_budget_tokens") + if raw_thinking_budget is None or raw_thinking_budget == "": + self.thinking_budget_tokens: Optional[int] = None + else: + budget = parse_positive_int(raw_thinking_budget, "thinking_budget_tokens") + if budget < MIN_ANTHROPIC_THINKING_BUDGET_TOKENS: + raise ValueError( + f"Invalid 'thinking_budget_tokens' ({budget}): must be at least {MIN_ANTHROPIC_THINKING_BUDGET_TOKENS}" + ) + if budget >= self.max_tokens: + raise ValueError( + f"Invalid 'thinking_budget_tokens' ({budget}): must be less than 'max_tokens' ({self.max_tokens})" + ) + self.thinking_budget_tokens = budget @property def api_base_url(self) -> str: @@ -514,32 +742,48 @@ def parse_event(event_text: str): pass def list_models(self) -> List[Dict[str, Any]]: - data = self._make_request("/models", stream=False) - raw_models = data if isinstance(data, list) else data.get("data", []) models: List[Dict[str, Any]] = [] - for m in raw_models: - if isinstance(m, str): - m_id = m - disp_name = m - desc = "" - elif isinstance(m, dict): - m_id = m.get("id") or m.get("name") - disp_name = m.get("display_name") or m.get("name") or m_id - desc = m.get("description", "") - else: - continue + after_id: Optional[str] = None + seen_cursors: Set[str] = set() + + while True: + endpoint = "/models?limit=1000" + if after_id: + endpoint += f"&after_id={urllib.parse.quote(str(after_id))}" + data = self._make_request(endpoint, stream=False) + raw_models = data if isinstance(data, list) else data.get("data", []) + for m in raw_models: + if isinstance(m, str): + m_id = m + disp_name = m + desc = "" + elif isinstance(m, dict): + m_id = m.get("id") or m.get("name") + disp_name = m.get("display_name") or m.get("name") or m_id + desc = m.get("description", "") + else: + continue - if not m_id: - continue + if not m_id: + continue + + models.append( + { + "name": m_id, + "display_name": disp_name, + "description": desc, + "supported_actions": ["generateContent"], + } + ) + + if not isinstance(data, dict) or not data.get("has_more"): + break + last_id = data.get("last_id") + if not last_id or last_id in seen_cursors or not raw_models: + break + seen_cursors.add(last_id) + after_id = last_id - models.append( - { - "name": m_id, - "display_name": disp_name, - "description": desc, - "supported_actions": ["generateContent"], - } - ) return models def generate_content( @@ -566,6 +810,11 @@ def generate_content( } if system_instruction: payload["system"] = system_instruction + if self.thinking_budget_tokens: + payload["thinking"] = { + "type": "enabled", + "budget_tokens": self.thinking_budget_tokens, + } data = self._make_request("/messages", data=payload, stream=False) content = data.get("content", []) @@ -604,6 +853,11 @@ def generate_content_stream( } if system_instruction: payload["system"] = system_instruction + if self.thinking_budget_tokens: + payload["thinking"] = { + "type": "enabled", + "budget_tokens": self.thinking_budget_tokens, + } resp = self._make_request("/messages", data=payload, stream=True) for event_data in self._read_sse_stream(resp): diff --git a/python3/vimini/common/config.py b/python3/vimini/common/config.py index 23a5df6..9cb8bd5 100644 --- a/python3/vimini/common/config.py +++ b/python3/vimini/common/config.py @@ -11,6 +11,8 @@ DEFAULT_GEMINI_KEY_FILE = "~/.config/gemini.token" DEFAULT_ANTHROPIC_KEY_FILE = "~/.config/anthropic.token" DEFAULT_ANTHROPIC_MODEL = "claude-3-7-sonnet-20250219" +DEFAULT_ANTHROPIC_MAX_TOKENS = 4096 +MIN_ANTHROPIC_THINKING_BUDGET_TOKENS = 1024 SUPPORTED_API_TYPES = {"genai", "openai", "anthropic"} VERSIONED_API_TYPES = ("openai", "anthropic") DEFAULT_API_VERSION = "v1" @@ -24,6 +26,10 @@ "prompt_hint_color": "Green", } +# Global variable holding the current configuration in memory so the config file +# is only read at plugin/agent startup or on a full reload. +_GLOBAL_CONFIG = None + def _to_bool(value, default=False): if isinstance(value, bool): @@ -131,7 +137,7 @@ def create_default_global_config(default_model=None, api_key_file=None): Generates a default v1 configuration dictionary. """ return { - "active_backend": DEFAULT_BACKEND, + "default_backend": DEFAULT_BACKEND, "backends": { DEFAULT_BACKEND: { "api": "genai", @@ -143,6 +149,23 @@ def create_default_global_config(default_model=None, api_key_file=None): } +def parse_positive_int(value, field_name, backend_name=None): + prefix = ( + f"Backend '{backend_name}' '{field_name}'" + if backend_name + else f"Invalid '{field_name}'" + ) + if isinstance(value, bool): + raise ValueError(f"{prefix} must be a positive integer") + try: + parsed = int(value) + except (TypeError, ValueError) as e: + raise ValueError(f"{prefix} must be a positive integer") from e + if parsed <= 0: + raise ValueError(f"{prefix} must be a positive integer") + return parsed + + def validate_global_config(config): """ Validates the structure of global configuration data. @@ -153,22 +176,24 @@ def validate_global_config(config): f"Global configuration must be a dictionary, got {type(config).__name__}" ) - active_backend = config.get("active_backend") - if not isinstance(active_backend, str) or not active_backend.strip(): - raise ValueError( - "Global configuration must specify a non-empty 'active_backend'" - ) - backends = config.get("backends") if not isinstance(backends, dict) or not backends: raise ValueError( "Global configuration must contain a non-empty 'backends' dictionary" ) - if active_backend not in backends: - raise ValueError( - f"Active backend '{active_backend}' is not defined in 'backends'" - ) + # 'active_backend' is a runtime-only variable; if it exists in the stored + # configuration (e.g. from an older config file), ignore it without removing it. + if "default_backend" in config or "active_backend" not in config: + default_backend = config.get("default_backend") + if not isinstance(default_backend, str) or not default_backend.strip(): + raise ValueError( + "Global configuration must specify a non-empty 'default_backend'" + ) + if default_backend not in backends: + raise ValueError( + f"Default backend '{default_backend}' is not defined in 'backends'" + ) for name, backend_info in backends.items(): if not isinstance(backend_info, dict): @@ -200,6 +225,29 @@ def validate_global_config(config): f"Backend '{name}' must specify a non-empty 'default_model'" ) + raw_max_tokens = backend_info.get("max_tokens") + resolved_max_tokens = DEFAULT_ANTHROPIC_MAX_TOKENS + if raw_max_tokens is not None: + resolved_max_tokens = parse_positive_int( + raw_max_tokens, "max_tokens", backend_name=name + ) + + raw_thinking_budget = backend_info.get("thinking_budget_tokens") + if raw_thinking_budget is not None: + resolved_budget = parse_positive_int( + raw_thinking_budget, "thinking_budget_tokens", backend_name=name + ) + if resolved_budget < MIN_ANTHROPIC_THINKING_BUDGET_TOKENS: + raise ValueError( + f"Backend '{name}' 'thinking_budget_tokens' ({resolved_budget}) " + f"must be at least {MIN_ANTHROPIC_THINKING_BUDGET_TOKENS}" + ) + if resolved_budget >= resolved_max_tokens: + raise ValueError( + f"Backend '{name}' 'thinking_budget_tokens' ({resolved_budget}) " + f"must be less than 'max_tokens' ({resolved_max_tokens})" + ) + ui = config.get("ui") if ui is not None: if not isinstance(ui, dict): @@ -214,11 +262,42 @@ def validate_global_config(config): return config +def set_global_config(config): + """ + Sets the in-memory global configuration dictionary. + """ + global _GLOBAL_CONFIG + _GLOBAL_CONFIG = config + return _GLOBAL_CONFIG + + +def get_global_config(config_path=None, default_model=None, api_key_file=None): + """ + Returns the current in-memory global configuration dictionary. + If not yet loaded into memory (or if a specific config_path is requested), + loads it once via load_global_config(). + """ + global _GLOBAL_CONFIG + if config_path is not None: + return load_global_config( + config_path=config_path, + default_model=default_model, + api_key_file=api_key_file, + ) + if _GLOBAL_CONFIG is None: + _GLOBAL_CONFIG = load_global_config( + default_model=default_model, api_key_file=api_key_file + ) + return _GLOBAL_CONFIG + + def load_global_config(config_path=None, default_model=None, api_key_file=None): """ - Loads configuration from ~/.var/vimini/config.json. + Loads configuration from ~/.var/vimini/config.json into the in-memory + global configuration variable. If the file does not exist, generates a default v1 config, saves it, and returns it. """ + global _GLOBAL_CONFIG target_path = ( os.path.realpath(os.path.expanduser(config_path)) if config_path @@ -230,6 +309,8 @@ def load_global_config(config_path=None, default_model=None, api_key_file=None): default_model=default_model, api_key_file=api_key_file ) save_global_config(default_config, config_path=target_path) + if config_path is None: + _GLOBAL_CONFIG = default_config return default_config try: @@ -252,13 +333,17 @@ def load_global_config(config_path=None, default_model=None, api_key_file=None): f"Failed to persist normalized base_url values to '{target_path}': {e}" ) + if config_path is None: + _GLOBAL_CONFIG = config return config def save_global_config(config, config_path=None): """ - Saves the global configuration dictionary to ~/.var/vimini/config.json. + Saves the global configuration dictionary to ~/.var/vimini/config.json + and updates the in-memory global configuration variable. """ + global _GLOBAL_CONFIG target_path = ( os.path.realpath(os.path.expanduser(config_path)) if config_path @@ -281,32 +366,44 @@ def save_global_config(config, config_path=None): except OSError: pass + default_real_path = os.path.realpath(os.path.expanduser(DEFAULT_CONFIG_PATH)) + if config_path is None or target_path == default_real_path: + _GLOBAL_CONFIG = config + return True -def get_active_backend(config): +def get_default_backend(config): """ - Returns the name of the active backend. + Returns the name of the default backend configured for startup. + Ignores any 'active_backend' key if present in the configuration. """ if isinstance(config, dict): - return config.get("active_backend", DEFAULT_BACKEND) + default_backend = config.get("default_backend") + if isinstance(default_backend, str) and default_backend.strip(): + return default_backend.strip() + backends = config.get("backends") + if isinstance(backends, dict) and backends: + if DEFAULT_BACKEND in backends: + return DEFAULT_BACKEND + return next(iter(backends.keys())) return DEFAULT_BACKEND -def get_active_backend_config(config): +def get_default_backend_config(config): """ - Returns a tuple (backend_name, backend_config_dict) for the active backend. + Returns a tuple (backend_name, backend_config_dict) for the default backend. """ if not isinstance(config, dict): return None, None - name = get_active_backend(config) + name = get_default_backend(config) backends = config.get("backends", {}) return name, backends.get(name) -def set_active_backend(backend_name, config=None, config_path=None): +def set_default_backend(backend_name, config=None, config_path=None): """ - Switches the active backend in the configuration and saves the changes. + Updates the default_backend in the configuration and saves the changes. """ target_path = ( os.path.realpath(os.path.expanduser(config_path)) @@ -315,7 +412,10 @@ def set_active_backend(backend_name, config=None, config_path=None): ) if config is None: - config = load_global_config(config_path=target_path) + if config_path is None: + config = get_global_config() + else: + config = load_global_config(config_path=target_path) backends = config.get("backends", {}) if backend_name not in backends: @@ -324,7 +424,7 @@ def set_active_backend(backend_name, config=None, config_path=None): f"Backend '{backend_name}' not found. Available backends: {available}" ) - config["active_backend"] = backend_name + config["default_backend"] = backend_name save_global_config(config, config_path=target_path) return config diff --git a/python3/vimini/config.py b/python3/vimini/config.py index 8080629..5790eca 100644 --- a/python3/vimini/config.py +++ b/python3/vimini/config.py @@ -13,9 +13,13 @@ get_project_name, ) from vimini.common.config import ( + MIN_ANTHROPIC_THINKING_BUDGET_TOKENS, + get_default_backend, + get_global_config, get_global_config_path, - load_global_config, + parse_positive_int, save_global_config, + set_global_config, strip_api_version, validate_global_config, ) @@ -110,6 +114,10 @@ def _ordered_backend_fields(backend_cfg): fields.append(key) if backend_cfg.get("api") in ("openai", "anthropic") or "base_url" in backend_cfg: fields.append("base_url") + if backend_cfg.get("api") == "anthropic": + for key in ("max_tokens", "thinking_budget_tokens"): + if key not in fields: + fields.append(key) for key in backend_cfg.keys(): if key not in fields: fields.append(key) @@ -118,7 +126,7 @@ def _ordered_backend_fields(backend_cfg): def _draw_global_config_listing(config_data, config_path=None): config_path = config_path or get_global_config_path() - active_backend = config_data.get("active_backend", "") + default_backend = get_default_backend(config_data) backends = config_data.get("backends", {}) ui_cfg = config_data.get("ui", {}) if not isinstance(ui_cfg, dict): @@ -143,10 +151,23 @@ def _draw_global_config_listing(config_data, config_path=None): _add_mapped_line( lines, line_map, - f" active_backend = {active_backend or '(not set)'}", - ("global", "active_backend"), + f" default_backend = {default_backend or '(not set)'}", + ("global", "default_backend"), ) - _add_mapped_line(lines, line_map, " # Currently selected model backend") + _add_mapped_line(lines, line_map, " # Default model backend used at startup") + if "active_backend" in config_data: + active_backend = config_data.get("active_backend", "") + _add_mapped_line( + lines, + line_map, + f" active_backend = {active_backend or '(not set)'}", + ("global", "active_backend"), + ) + _add_mapped_line( + lines, + line_map, + " # Deprecated setting (ignored); press d to delete", + ) _add_mapped_line(lines, line_map, "") _add_mapped_line(lines, line_map, "> UI PREFERENCES") @@ -185,7 +206,7 @@ def _draw_global_config_listing(config_data, config_path=None): if not isinstance(backend_cfg, dict): backend_cfg = {} _add_mapped_line(lines, line_map, "") - marker = "*" if backend_name == active_backend else " " + marker = "*" if backend_name == default_backend else " " _add_mapped_line( lines, line_map, @@ -289,7 +310,7 @@ def global_config_command(): util.log_info("global_config_command()") try: config_path = get_global_config_path() - data = load_global_config(config_path=config_path) + data = get_global_config() _VIMINI_ORIGINAL_GLOBAL_CONFIG = copy.deepcopy(data) _VIMINI_PENDING_GLOBAL_CONFIG = copy.deepcopy(data) _VIMINI_GLOBAL_CONFIG_PATH = config_path @@ -347,23 +368,29 @@ def edit_global_config_option(): backends = _VIMINI_PENDING_GLOBAL_CONFIG.setdefault("backends", {}) kind = target[0] - if kind == "global" and target[1] == "active_backend": + if kind == "global" and target[1] == "default_backend": names = list(backends.keys()) - current = _VIMINI_PENDING_GLOBAL_CONFIG.get("active_backend", "") - prompt = f"Active backend ({', '.join(names)}): " + current = get_default_backend(_VIMINI_PENDING_GLOBAL_CONFIG) + prompt = f"Default backend ({', '.join(names)}): " new_val = _to_str( vim.eval(f"input({_vim_string(prompt)}, {_vim_string(current)})") ).strip() if new_val and new_val in backends: - _VIMINI_PENDING_GLOBAL_CONFIG["active_backend"] = new_val + _VIMINI_PENDING_GLOBAL_CONFIG["default_backend"] = new_val _refresh_global_config_buffer(win, line_num, col) - util.display_message(f"Set active backend to '{new_val}'.") + util.display_message(f"Set default backend to '{new_val}'.") elif new_val: util.display_message( f"Backend '{new_val}' is not configured.", error=True ) return + if kind == "global" and target[1] == "active_backend": + util.display_message( + "active_backend is deprecated and ignored; press 'd' to delete it." + ) + return + if kind == "ui": field = target[1] from vimini.common.config import DEFAULT_UI_CONFIG @@ -402,6 +429,18 @@ def edit_global_config_option(): # 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) + elif field in ("max_tokens", "thinking_budget_tokens"): + new_val = parse_positive_int( + new_val, field, backend_name=backend_name + ) + if ( + field == "thinking_budget_tokens" + and new_val < MIN_ANTHROPIC_THINKING_BUDGET_TOKENS + ): + raise ValueError( + f"Backend '{backend_name}' 'thinking_budget_tokens' ({new_val}) " + f"must be at least {MIN_ANTHROPIC_THINKING_BUDGET_TOKENS}" + ) backend_cfg[field] = new_val _refresh_global_config_buffer(win, line_num, col) util.display_message(f"Updated '{backend_name}.{field}'.") @@ -419,8 +458,8 @@ def edit_global_config_option(): ) return backends[new_name] = backends.pop(backend_name) - if _VIMINI_PENDING_GLOBAL_CONFIG.get("active_backend") == backend_name: - _VIMINI_PENDING_GLOBAL_CONFIG["active_backend"] = new_name + if get_default_backend(_VIMINI_PENDING_GLOBAL_CONFIG) == backend_name: + _VIMINI_PENDING_GLOBAL_CONFIG["default_backend"] = new_name _refresh_global_config_buffer(win, line_num, col) util.display_message( f"Renamed backend '{backend_name}' to '{new_name}'." @@ -464,8 +503,8 @@ def add_global_backend(): if base_url: backend_cfg["base_url"] = base_url backends[name] = backend_cfg - if not _VIMINI_PENDING_GLOBAL_CONFIG.get("active_backend"): - _VIMINI_PENDING_GLOBAL_CONFIG["active_backend"] = name + if not _VIMINI_PENDING_GLOBAL_CONFIG.get("default_backend"): + _VIMINI_PENDING_GLOBAL_CONFIG["default_backend"] = name _refresh_global_config_buffer(vim.current.window, *vim.current.window.cursor) util.display_message(f"Added backend '{name}'.") except Exception as e: @@ -561,16 +600,25 @@ def delete_or_clear_global_config_option(): ) return if prompt_delete_backend_dialog(backend_name): + default_before = get_default_backend(_VIMINI_PENDING_GLOBAL_CONFIG) backends.pop(backend_name, None) - if _VIMINI_PENDING_GLOBAL_CONFIG.get("active_backend") == backend_name: - _VIMINI_PENDING_GLOBAL_CONFIG["active_backend"] = next( + if default_before == backend_name: + _VIMINI_PENDING_GLOBAL_CONFIG["default_backend"] = next( iter(backends.keys()) ) _refresh_global_config_buffer(win, line_num, col) util.display_message(f"Deleted backend '{backend_name}'.") + elif kind == "global" and target[1] == "active_backend": + if "default_backend" not in _VIMINI_PENDING_GLOBAL_CONFIG: + _VIMINI_PENDING_GLOBAL_CONFIG["default_backend"] = get_default_backend( + _VIMINI_PENDING_GLOBAL_CONFIG + ) + _VIMINI_PENDING_GLOBAL_CONFIG.pop("active_backend", None) + _refresh_global_config_buffer(win, line_num, col) + util.display_message("Deleted 'active_backend' from configuration.") else: util.display_message( - "active_backend cannot be cleared; select another backend instead." + "default_backend cannot be cleared; select another backend instead." ) except Exception as e: util.display_message(f"Error updating global configuration: {e}", error=True) @@ -1393,9 +1441,12 @@ def _apply_saved_global_config_runtime(config_data): try: from vimini import main - backend_name = config_data.get("active_backend") - if backend_name: - main.apply_backend_config(config_data, backend_name, persist=False) + backends = config_data.get("backends", {}) + backend_name = getattr(util, "_BACKEND", None) + if not backend_name or backend_name not in backends: + backend_name = get_default_backend(config_data) + if backend_name and backend_name in backends: + main.apply_backend_config(config_data, backend_name) util.setup_highlights(config_data) except Exception as e: util.log_info(f"Failed to apply saved global config at runtime: {e}") @@ -1467,6 +1518,7 @@ def confirm_global_config(): if save_global_config( _VIMINI_PENDING_GLOBAL_CONFIG, config_path=_VIMINI_GLOBAL_CONFIG_PATH ): + set_global_config(copy.deepcopy(_VIMINI_PENDING_GLOBAL_CONFIG)) _apply_saved_global_config_runtime(_VIMINI_PENDING_GLOBAL_CONFIG) util.display_message( "Global configuration updated and saved.", history=True diff --git a/python3/vimini/main.py b/python3/vimini/main.py index c6ba877..4315460 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -53,13 +53,14 @@ def _get_backend_default_model(backend_name, backend_cfg, default_model): def initialize(api_key_file, model=None, logfile=None, backend=None): """ Initializes the plugin with the user's API key file path and optional logfile path. - The model is always read from the active backend in the global config. + The default backend and model are read from the global config at startup. This function is called from the plugin's Vimscript entry point. """ from vimini.common.config import ( DEFAULT_MODEL, load_global_config, - get_active_backend, + set_global_config, + get_default_backend, backend_requires_api_key, ) @@ -67,8 +68,9 @@ def initialize(api_key_file, model=None, logfile=None, backend=None): active_b_cfg = {} try: cfg = load_global_config(api_key_file=api_key_file) + set_global_config(cfg) if not backend: - backend = get_active_backend(cfg) + backend = get_default_backend(cfg) util._BACKEND = backend active_b_cfg = cfg.get("backends", {}).get(backend, {}) api_key_file = ( @@ -157,10 +159,10 @@ def send_setup(): backend_name = None if not backend_name: try: - from vimini.common.config import load_global_config, get_active_backend + from vimini.common.config import get_global_config, get_default_backend - cfg = load_global_config() - backend_name = get_active_backend(cfg) + cfg = get_global_config() + backend_name = get_default_backend(cfg) except Exception: backend_name = "Gemini" req = { @@ -175,12 +177,12 @@ def send_setup(): return util.send_channel_request(req, silent=False) -def apply_backend_config(cfg, backend_name, persist=True): +def apply_backend_config(cfg, backend_name): """ Applies a backend from a loaded global config to the current Vim/runtime state. - Optionally persists active_backend in the config file. + The active backend is a runtime-only variable and is not persisted to disk. """ - from vimini.common.config import set_active_backend, DEFAULT_MODEL + from vimini.common.config import DEFAULT_MODEL backends = cfg.get("backends", {}) if isinstance(cfg, dict) else {} if backend_name not in backends: @@ -190,9 +192,6 @@ def apply_backend_config(cfg, backend_name, persist=True): target_cfg = backends[backend_name] default_model = _get_backend_default_model(backend_name, target_cfg, DEFAULT_MODEL) - if persist: - set_active_backend(backend_name) - util._BACKEND = backend_name try: vim.command(f"let g:vimini_backend = {json.dumps(backend_name)}") @@ -217,23 +216,25 @@ def apply_backend_config(cfg, backend_name, persist=True): def backend_command(backend_name=None): """ Handles :ViminiBackend [backend_name]. - Without arguments, echoes active backend and lists other available backends. - With argument, validates backend exists, updates active backend, updates model to default, - and sends setup to agent server. + Without arguments, echoes active runtime backend and lists other available backends. + With argument, validates backend exists, updates active runtime backend, + updates model to default, and sends setup to agent server. """ from vimini.common.config import ( - load_global_config, - get_active_backend, + get_global_config, + get_default_backend, ) try: - cfg = load_global_config() + cfg = get_global_config() except Exception as e: util.display_message(f"Error loading configuration: {e}", error=True) return - active = get_active_backend(cfg) backends = cfg.get("backends", {}) + active = getattr(util, "_BACKEND", None) + if not active or active not in backends: + active = get_default_backend(cfg) backend_name_str = str(backend_name).strip() if backend_name else "" if not backend_name_str: @@ -257,7 +258,7 @@ def backend_command(backend_name=None): return try: - apply_backend_config(cfg, backend_name_str, persist=True) + apply_backend_config(cfg, backend_name_str) except Exception as e: util.display_message(f"Error setting active backend: {e}", error=True) return @@ -271,10 +272,10 @@ def get_available_backends(arg_lead=""): """ Returns a list of backend names matching arg_lead for command completion. """ - from vimini.common.config import load_global_config + from vimini.common.config import get_global_config try: - cfg = load_global_config() + cfg = get_global_config() backends = list(cfg.get("backends", {}).keys()) if arg_lead: return [b for b in backends if b.lower().startswith(arg_lead.lower())] @@ -387,6 +388,7 @@ def reload_vimini(): from vimini import util as old_util api_key_file = old_util._API_KEY_FILE + backend = old_util._BACKEND job_counter = old_util._JOB_COUNTER log_file = None if old_util._LOGGER and old_util._LOGGER.handlers: @@ -398,6 +400,7 @@ def reload_vimini(): break except Exception: api_key_file = os.path.expanduser("~/.config/gemini.token") + backend = None job_counter = 0 log_file = vim.eval("get(g:, 'vimini_log_file', '')") if not log_file or vim.eval("get(g:, 'vimini_logging', 'off')") != "on": @@ -411,7 +414,7 @@ def reload_vimini(): # Re-import main and re-initialize from vimini import main - main.initialize(api_key_file=api_key_file, logfile=log_file) + main.initialize(api_key_file=api_key_file, logfile=log_file, backend=backend) # Use the freshly imported util to display the message and restore _JOB_COUNTER from vimini import util as new_util diff --git a/python3/vimini/models.py b/python3/vimini/models.py index faacfdf..cd65efd 100644 --- a/python3/vimini/models.py +++ b/python3/vimini/models.py @@ -148,11 +148,11 @@ def build_models_buffer(models, current_model, backend_name=None, api_type=None) Returns (buffer_lines, line_map, current_model_line). """ if backend_name is None or api_type is None: - from vimini.common.config import load_global_config, get_active_backend + from vimini.common.config import get_global_config, get_default_backend try: - cfg = load_global_config() - backend_name = getattr(util, "_BACKEND", None) or get_active_backend(cfg) + cfg = get_global_config() + backend_name = getattr(util, "_BACKEND", None) or get_default_backend(cfg) b_cfg = cfg.get("backends", {}).get(backend_name, {}) api_type = b_cfg.get("api", "genai") except Exception: @@ -389,12 +389,12 @@ def set_default_model(): model_name = _normalized_model_name(entry) - from vimini.common.config import load_global_config, save_global_config - from vimini.common.config import get_active_backend + from vimini.common.config import get_global_config, save_global_config + from vimini.common.config import get_default_backend from vimini import main - cfg = load_global_config() - backend_name = getattr(util, "_BACKEND", None) or get_active_backend(cfg) + cfg = get_global_config() + backend_name = getattr(util, "_BACKEND", None) or get_default_backend(cfg) backends = cfg.get("backends", {}) if backend_name not in backends: util.display_message( diff --git a/python3/vimini/util.py b/python3/vimini/util.py index 5591fef..9f4823e 100644 --- a/python3/vimini/util.py +++ b/python3/vimini/util.py @@ -227,10 +227,10 @@ def get_model_name(): backend = _BACKEND api_type = None try: - from vimini.common.config import load_global_config, get_active_backend + from vimini.common.config import get_global_config, get_default_backend - cfg = load_global_config() - backend = backend or get_active_backend(cfg) + cfg = get_global_config() + backend = backend or get_default_backend(cfg) b_cfg = cfg.get("backends", {}).get(backend) if isinstance(b_cfg, dict): api_type = b_cfg.get("api", "genai") @@ -267,9 +267,9 @@ def setup_highlights(config=None): ui_cfg = config.get("ui") or {} else: try: - from vimini.common.config import load_global_config + from vimini.common.config import get_global_config - cfg = load_global_config() + cfg = get_global_config() ui_cfg = cfg.get("ui") or {} except Exception: ui_cfg = {} @@ -948,11 +948,11 @@ def update_status_buffer(): # so it updates when we switch back to the tab with status window. return - from vimini.common.config import load_global_config, get_active_backend + from vimini.common.config import get_global_config, get_default_backend try: - cfg = load_global_config() - backend_name = _BACKEND or get_active_backend(cfg) + cfg = get_global_config() + backend_name = _BACKEND or get_default_backend(cfg) b_cfg = cfg.get("backends", {}).get(backend_name, {}) api_type = b_cfg.get("api", "genai") endpoint = b_cfg.get( diff --git a/tests/test_agent_server.py b/tests/test_agent_server.py index 4d668e5..6723760 100644 --- a/tests/test_agent_server.py +++ b/tests/test_agent_server.py @@ -1,5 +1,6 @@ import os import sys +from unittest.mock import patch sys.path.insert( 0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "python3")) @@ -8,7 +9,7 @@ from vimini.agent import server -def test_setup_strips_api_key_fields(): +def test_setup_strips_api_key_fields_and_reloads_global_config(): server.AGENT_CONFIG.clear() class DummyQueue: @@ -26,7 +27,9 @@ def put(self, item): "api_key": "secret", } - server.execute_function("setup", "setup", params, queue, object()) + with patch.object(server, "load_global_config") as mock_load: + server.execute_function("setup", "setup", params, queue, object()) + mock_load.assert_called_once() assert server.AGENT_CONFIG == { "backend": "Gemini", diff --git a/tests/test_anthropic_backend.py b/tests/test_anthropic_backend.py index 34aa09e..7f1a29c 100644 --- a/tests/test_anthropic_backend.py +++ b/tests/test_anthropic_backend.py @@ -108,6 +108,7 @@ def test_anthropic_backend_init(): "base_url": "https://api.anthropic.com", "headers": {"X-Custom": "custom-val"}, "max_tokens": 8192, + "thinking_budget_tokens": 2048, "anthropic_version": "2023-06-01", }, api_key="sk-ant-test", @@ -118,9 +119,42 @@ def test_anthropic_backend_init(): assert backend.api_key == "sk-ant-test" assert backend.headers == {"X-Custom": "custom-val"} assert backend.max_tokens == 8192 + assert backend.thinking_budget_tokens == 2048 assert backend.anthropic_version == "2023-06-01" +def test_anthropic_backend_invalid_max_tokens_and_thinking_budget(): + with pytest.raises(ValueError, match="Invalid 'max_tokens'"): + AnthropicBackend(config={"max_tokens": "not-an-int"}) + + with pytest.raises(ValueError, match="Invalid 'max_tokens'"): + AnthropicBackend(config={"max_tokens": 0}) + + with pytest.raises(ValueError, match="Invalid 'max_tokens'"): + AnthropicBackend(config={"max_tokens": -100}) + + with pytest.raises(ValueError, match="Invalid 'max_tokens'"): + AnthropicBackend(config={"max_tokens": True}) + + with pytest.raises(ValueError, match="Invalid 'thinking_budget_tokens'"): + AnthropicBackend(config={"thinking_budget_tokens": "bad"}) + + with pytest.raises(ValueError, match="Invalid 'thinking_budget_tokens'"): + AnthropicBackend(config={"thinking_budget_tokens": 0}) + + with pytest.raises( + ValueError, + match="Invalid 'thinking_budget_tokens' \\(512\\): must be at least 1024", + ): + AnthropicBackend(config={"max_tokens": 4096, "thinking_budget_tokens": 512}) + + with pytest.raises( + ValueError, + match="Invalid 'thinking_budget_tokens' \\(4096\\): must be less than 'max_tokens' \\(4096\\)", + ): + AnthropicBackend(config={"max_tokens": 4096, "thinking_budget_tokens": 4096}) + + def test_anthropic_backend_make_request_headers(): backend = AnthropicBackend( base_url="https://api.anthropic.com/v1", @@ -173,8 +207,9 @@ def test_anthropic_backend_list_models(): ] } - with patch.object(backend, "_make_request", return_value=mock_data): + with patch.object(backend, "_make_request", return_value=mock_data) as mock_req: models = backend.list_models() + mock_req.assert_called_once_with("/models?limit=1000", stream=False) assert len(models) == 2 assert models[0]["name"] == "claude-3-7-sonnet-20250219" assert models[0]["display_name"] == "Claude 3.7 Sonnet" @@ -185,8 +220,44 @@ def test_anthropic_backend_list_models(): assert models[1]["display_name"] == "Claude 3.5 Haiku" -def test_anthropic_backend_generate_content(): +def test_anthropic_backend_list_models_pagination(): backend = AnthropicBackend() + page1 = { + "data": [ + {"id": "claude-opus-4", "display_name": "Claude Opus 4"}, + {"id": "claude-sonnet-4", "display_name": "Claude Sonnet 4"}, + ], + "has_more": True, + "last_id": "claude-sonnet-4", + } + page2 = { + "data": [ + {"id": "claude-3-5-haiku", "display_name": "Claude 3.5 Haiku"}, + ], + "has_more": False, + "last_id": "claude-3-5-haiku", + } + + with patch.object(backend, "_make_request", side_effect=[page1, page2]) as mock_req: + models = backend.list_models() + assert len(models) == 3 + assert [m["name"] for m in models] == [ + "claude-opus-4", + "claude-sonnet-4", + "claude-3-5-haiku", + ] + assert mock_req.call_count == 2 + assert mock_req.call_args_list[0][0][0] == "/models?limit=1000" + assert ( + mock_req.call_args_list[1][0][0] + == "/models?limit=1000&after_id=claude-sonnet-4" + ) + + +def test_anthropic_backend_generate_content(): + backend = AnthropicBackend( + config={"max_tokens": 8192, "thinking_budget_tokens": 1024} + ) mock_resp_data = { "content": [ { @@ -211,6 +282,7 @@ def test_anthropic_backend_generate_content(): assert payload["system"] == "Be helpful" assert payload["messages"] == [{"role": "user", "content": "Hello"}] assert payload["stream"] is False + assert payload["thinking"] == {"type": "enabled", "budget_tokens": 1024} def test_anthropic_backend_generate_content_stream(): @@ -370,6 +442,272 @@ def test_anthropic_chat_session_streaming_and_tools(): assert messages[2]["content"][0]["content"] == "print('hello')" +def test_anthropic_chat_session_thinking_signature_and_redacted_thinking(): + backend = AnthropicBackend( + config={"max_tokens": 8192, "thinking_budget_tokens": 2048} + ) + session = backend.create_chat(model="claude-3-7-sonnet-20250219") + + # Block 0: thinking WITH signature -> replayed + # Block 1: thinking WITHOUT signature -> dropped from history + # Block 2: redacted_thinking -> echoed back verbatim + # Block 3: text -> replayed + events = [ + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "Signed thought."}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "sig_valid_123"}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "thinking", "thinking": ""}, + }, + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "thinking_delta", "thinking": "Unsigned thought."}, + }, + {"type": "content_block_stop", "index": 1}, + { + "type": "content_block_start", + "index": 2, + "content_block": { + "type": "redacted_thinking", + "data": "EvkBCg1yZWRhY3RlZF9kYXRh", + }, + }, + {"type": "content_block_stop", "index": 2}, + { + "type": "content_block_start", + "index": 3, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 3, + "delta": {"type": "text_delta", "text": "Final answer."}, + }, + {"type": "content_block_stop", "index": 3}, + {"type": "message_stop"}, + ] + + mock_resp = io.BytesIO( + b"".join([f"data: {json.dumps(ev)}\n\n".encode("utf-8") for ev in events]) + ) + with patch.object(backend, "_make_request", return_value=mock_resp) as mock_req: + chunks = list(session.send_message_stream("Solve this")) + assert mock_req.call_args[1]["data"]["thinking"] == { + "type": "enabled", + "budget_tokens": 2048, + } + assert [c.thought for c in chunks if c.thought] == [ + "Signed thought.", + "Unsigned thought.", + ] + assert [c.text for c in chunks if c.text] == ["Final answer."] + + asst_msg = session.messages[-1] + assert asst_msg["role"] == "assistant" + assert asst_msg["content"] == [ + { + "type": "thinking", + "thinking": "Signed thought.", + "signature": "sig_valid_123", + }, + { + "type": "redacted_thinking", + "data": "EvkBCg1yZWRhY3RlZF9kYXRh", + }, + { + "type": "text", + "text": "Final answer.", + }, + ] + + +def test_anthropic_chat_session_unanswered_tool_use_synthesizes_tool_result(): + backend = AnthropicBackend() + session = backend.create_chat( + model="claude-3-5-sonnet-20241022", + tools=[{"name": "read_file", "parameters": {}}], + ) + + events_turn1 = [ + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_pending_1", + "name": "read_file", + "input": {}, + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": '{"filepath": "foo.py"}', + }, + }, + {"type": "content_block_stop", "index": 0}, + {"type": "message_stop"}, + ] + mock_resp1 = io.BytesIO( + b"".join([f"data: {json.dumps(ev)}\n\n".encode("utf-8") for ev in events_turn1]) + ) + with patch.object(backend, "_make_request", return_value=mock_resp1): + list(session.send_message_stream("Read foo.py")) + + # User cancels tool execution and sends a plain prompt instead. + mock_resp2 = io.BytesIO(b'data: {"type": "message_stop"}\n\n') + with patch.object(backend, "_make_request", return_value=mock_resp2) as mock_req2: + list(session.send_message_stream("Cancel that, do something else")) + messages = mock_req2.call_args[1]["data"]["messages"] + + assert len(messages) == 3 + assert messages[1]["role"] == "assistant" + assert messages[2]["role"] == "user" + assert messages[2]["content"] == [ + { + "type": "tool_result", + "tool_use_id": "toolu_pending_1", + "content": "Tool call not executed.", + "is_error": True, + }, + { + "type": "text", + "text": "Cancel that, do something else", + }, + ] + + +def test_anthropic_chat_session_role_dict_inputs_preserve_invariants(): + backend = AnthropicBackend() + session = backend.create_chat( + model="claude-3-5-sonnet-20241022", + tools=[{"name": "read_file", "parameters": {}}], + ) + + # Seed an assistant turn with an unanswered tool_use block + session.messages = [ + {"role": "user", "content": "Read foo.py"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_pending_role", + "name": "read_file", + "input": {"filepath": "foo.py"}, + } + ], + }, + ] + + # Sending a list with a ToolResponse and a raw {"role": "user", ...} dict + # merges them into a single user message rather than creating consecutive user turns. + mock_resp = io.BytesIO(b'data: {"type": "message_stop"}\n\n') + with patch.object(backend, "_make_request", return_value=mock_resp) as mock_req: + list( + session.send_message_stream( + [ + ToolResponse( + name="read_file", + response="contents", + call_id="toolu_pending_role", + ), + {"role": "user", "content": "Now summarize it."}, + ] + ) + ) + messages = mock_req.call_args[1]["data"]["messages"] + assert len(messages) == 3 + assert messages[2]["role"] == "user" + assert messages[2]["content"] == [ + { + "type": "tool_result", + "tool_use_id": "toolu_pending_role", + "content": "contents", + }, + {"type": "text", "text": "Now summarize it."}, + ] + + +def test_anthropic_chat_session_tool_response_without_call_id(): + backend = AnthropicBackend() + session = backend.create_chat(model="claude-3-5-sonnet-20241022") + + # Turn 1: assistant emits tool_use with id 'toolu_hist_99' + events_turn1 = [ + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_hist_99", + "name": "read_file", + "input": {"filepath": "a.py"}, + }, + }, + {"type": "content_block_stop", "index": 0}, + {"type": "message_stop"}, + ] + mock_resp1 = io.BytesIO( + b"".join([f"data: {json.dumps(ev)}\n\n".encode("utf-8") for ev in events_turn1]) + ) + with patch.object(backend, "_make_request", return_value=mock_resp1): + list(session.send_message_stream("Read a.py")) + + # Turn 2: ToolResponse with call_id=None falls back to pending id in history + mock_resp2 = io.BytesIO(b'data: {"type": "message_stop"}\n\n') + with patch.object(backend, "_make_request", return_value=mock_resp2) as mock_req2: + list( + session.send_message_stream( + ToolResponse(name="read_file", response="ok", call_id=None) + ) + ) + messages = mock_req2.call_args[1]["data"]["messages"] + assert messages[2]["content"] == [ + { + "type": "tool_result", + "tool_use_id": "toolu_hist_99", + "content": "ok", + } + ] + + # When no pending tool_use is in history, falls back to deterministic 'toolu_{i}' + fresh_session = backend.create_chat(model="claude-3-5-sonnet-20241022") + mock_resp3 = io.BytesIO(b'data: {"type": "message_stop"}\n\n') + with patch.object(backend, "_make_request", return_value=mock_resp3) as mock_req3: + list( + fresh_session.send_message_stream( + [ + ToolResponse(name="t1", response="r1", call_id=None), + ToolResponse(name="t2", response="r2", call_id=None), + ] + ) + ) + messages = mock_req3.call_args[1]["data"]["messages"] + assert messages[0]["content"] == [ + {"type": "tool_result", "tool_use_id": "toolu_0", "content": "r1"}, + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "r2"}, + ] + + def test_anthropic_chat_session_multiple_tool_calls(): backend = AnthropicBackend() session = backend.create_chat( diff --git a/tests/test_backend_abstraction.py b/tests/test_backend_abstraction.py index c56bed2..ad49c89 100644 --- a/tests/test_backend_abstraction.py +++ b/tests/test_backend_abstraction.py @@ -200,7 +200,7 @@ def test_get_backend_allows_openai_without_api_key_when_not_required(): def test_get_backend_surfaces_global_config_load_errors(): with ( patch( - "vimini.common.backends.load_global_config", + "vimini.common.backends.get_global_config", side_effect=ValueError("bad config"), ), pytest.raises(ValueError, match="bad config"), diff --git a/tests/test_global_config.py b/tests/test_global_config.py index 1618f4d..2a302f2 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -17,11 +17,13 @@ normalize_global_config_base_urls, strip_api_version, validate_global_config, + get_global_config, + set_global_config, load_global_config, save_global_config, - get_active_backend, - get_active_backend_config, - set_active_backend, + get_default_backend, + get_default_backend_config, + set_default_backend, load_backend_api_key, backend_requires_api_key, ) @@ -29,7 +31,8 @@ def test_create_default_global_config(): cfg = create_default_global_config() - assert cfg["active_backend"] == DEFAULT_BACKEND + assert cfg["default_backend"] == DEFAULT_BACKEND + assert "active_backend" not in cfg assert DEFAULT_BACKEND in cfg["backends"] assert "ui" in cfg assert cfg["ui"]["thinking_color"] == DEFAULT_UI_CONFIG["thinking_color"] @@ -53,7 +56,8 @@ def test_load_global_config_creates_default_when_missing(tmp_path): cfg = load_global_config(config_path=str(config_file), default_model="gemini-test") assert config_file.exists() - assert cfg["active_backend"] == "Gemini" + assert cfg["default_backend"] == "Gemini" + assert "active_backend" not in cfg assert cfg["backends"]["Gemini"]["default_model"] == "gemini-test" # Verify content written to disk @@ -65,7 +69,7 @@ def test_load_global_config_creates_default_when_missing(tmp_path): def test_load_global_config_reads_existing(tmp_path): config_file = tmp_path / "config.json" data = { - "active_backend": "Custom 1", + "default_backend": "Custom 1", "backends": { "Gemini": { "api": "genai", @@ -83,7 +87,7 @@ def test_load_global_config_reads_existing(tmp_path): config_file.write_text(json.dumps(data), encoding="utf-8") cfg = load_global_config(config_path=str(config_file)) - assert cfg["active_backend"] == "Custom 1" + assert cfg["default_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" @@ -94,6 +98,32 @@ def test_load_global_config_reads_existing(tmp_path): assert disk_data["backends"]["Custom 1"]["base_url"] == "https://api.proxy.com" +def test_load_global_config_preserves_and_ignores_legacy_active_backend(tmp_path): + config_file = tmp_path / "config.json" + data = { + "active_backend": "Custom 1", + "backends": { + "Gemini": { + "api": "genai", + "api_key_file": "~/.config/gemini.token", + "default_model": "gemini-3.8-flash", + }, + "Custom 1": { + "api": "openai", + "base_url": "https://api.proxy.com", + "api_key_file": "~/.config/proxyapi.token", + "default_model": "gpt-4o", + }, + }, + } + config_file.write_text(json.dumps(data), encoding="utf-8") + + cfg = load_global_config(config_path=str(config_file)) + # active_backend is not removed, but is ignored when resolving the default backend + assert cfg["active_backend"] == "Custom 1" + assert get_default_backend(cfg) == "Gemini" + + 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" @@ -108,7 +138,7 @@ def test_strip_api_version(): def test_normalize_global_config_base_urls(): config = { - "active_backend": "Proxy", + "default_backend": "Proxy", "backends": { "Gemini": {"api": "genai", "base_url": "https://genai.example/v1"}, "Proxy": {"api": "openai", "base_url": "https://proxy.example/v1"}, @@ -141,24 +171,42 @@ def test_validate_global_config_errors(): with pytest.raises(ValueError, match="must be a dictionary"): validate_global_config(["not a dict"]) - with pytest.raises(ValueError, match="non-empty 'active_backend'"): - validate_global_config({"active_backend": "", "backends": {}}) - with pytest.raises(ValueError, match="non-empty 'backends' dictionary"): - validate_global_config({"active_backend": "Gemini", "backends": {}}) + validate_global_config({"default_backend": "Gemini", "backends": {}}) + + with pytest.raises(ValueError, match="non-empty 'default_backend'"): + validate_global_config( + { + "default_backend": "", + "backends": { + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"} + }, + } + ) with pytest.raises(ValueError, match="not defined in 'backends'"): validate_global_config( { - "active_backend": "Unknown", + "default_backend": "Unknown", "backends": {"Gemini": {"api": "genai"}}, } ) + # Legacy active_backend in config is ignored and not removed + legacy_cfg = { + "default_backend": "Gemini", + "active_backend": "StaleBackend", + "backends": { + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"} + }, + } + validated = validate_global_config(legacy_cfg) + assert validated["active_backend"] == "StaleBackend" + with pytest.raises(ValueError, match="must be a dictionary"): validate_global_config( { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": {"Gemini": "not a dict"}, } ) @@ -166,7 +214,7 @@ def test_validate_global_config_errors(): with pytest.raises(ValueError, match="must specify a non-empty 'api' type"): validate_global_config( { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": {"Gemini": {"api": ""}}, } ) @@ -174,7 +222,7 @@ def test_validate_global_config_errors(): with pytest.raises(ValueError, match="api 'unsupported' is not supported"): validate_global_config( { - "active_backend": "Unsupported", + "default_backend": "Unsupported", "backends": { "Unsupported": { "api": "unsupported", @@ -187,7 +235,7 @@ def test_validate_global_config_errors(): with pytest.raises(ValueError, match="must specify a non-empty 'api_key_file'"): validate_global_config( { - "active_backend": "Claude", + "default_backend": "Claude", "backends": { "Claude": { "api": "anthropic", @@ -200,7 +248,7 @@ def test_validate_global_config_errors(): with pytest.raises(ValueError, match="must specify a non-empty 'default_model'"): validate_global_config( { - "active_backend": "Claude", + "default_backend": "Claude", "backends": { "Claude": { "api": "anthropic", @@ -212,7 +260,7 @@ def test_validate_global_config_errors(): validate_global_config( { - "active_backend": "Claude", + "default_backend": "Claude", "backends": { "Claude": { "api": "anthropic", @@ -223,17 +271,68 @@ def test_validate_global_config_errors(): } ) + with pytest.raises(ValueError, match="'max_tokens' must be a positive integer"): + validate_global_config( + { + "default_backend": "Claude", + "backends": { + "Claude": { + "api": "anthropic", + "api_key_file": "~/.config/anthropic.token", + "default_model": "claude-3-7-sonnet-20250219", + "max_tokens": "not-an-int", + } + }, + } + ) + + with pytest.raises( + ValueError, + match="'thinking_budget_tokens' \\(512\\) must be at least 1024", + ): + validate_global_config( + { + "default_backend": "Claude", + "backends": { + "Claude": { + "api": "anthropic", + "api_key_file": "~/.config/anthropic.token", + "default_model": "claude-3-7-sonnet-20250219", + "thinking_budget_tokens": 512, + } + }, + } + ) + + with pytest.raises( + ValueError, + match="'thinking_budget_tokens' \\(4096\\) must be less than 'max_tokens' \\(4096\\)", + ): + validate_global_config( + { + "default_backend": "Claude", + "backends": { + "Claude": { + "api": "anthropic", + "api_key_file": "~/.config/anthropic.token", + "default_model": "claude-3-7-sonnet-20250219", + "thinking_budget_tokens": 4096, + } + }, + } + ) + with pytest.raises(ValueError, match="must specify a non-empty 'api_key_file'"): validate_global_config( { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": {"Gemini": {"api": "genai"}}, } ) validate_global_config( { - "active_backend": "Local", + "default_backend": "Local", "backends": { "Local": { "api": "openai", @@ -247,7 +346,7 @@ def test_validate_global_config_errors(): with pytest.raises(ValueError, match="must specify a non-empty 'default_model'"): validate_global_config( { - "active_backend": "Local", + "default_backend": "Local", "backends": { "Local": { "api": "openai", @@ -260,7 +359,7 @@ def test_validate_global_config_errors(): with pytest.raises(ValueError, match="must specify a non-empty 'api_key_file'"): validate_global_config( { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": {"Gemini": {"api": "genai", "api_key_file": ""}}, } ) @@ -268,7 +367,7 @@ def test_validate_global_config_errors(): with pytest.raises(ValueError, match="'api_key_file' must be a string path"): validate_global_config( { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": {"Gemini": {"api": "genai", "api_key_file": 123}}, } ) @@ -277,7 +376,7 @@ def test_validate_global_config_errors(): with pytest.raises(ValueError, match="'ui' must be a dictionary"): validate_global_config( { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": {"Gemini": {"api": "genai", "api_key_file": "~/.token"}}, "ui": "invalid", } @@ -286,7 +385,7 @@ def test_validate_global_config_errors(): with pytest.raises(ValueError, match="'ui.thinking_color' must be a string"): validate_global_config( { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": {"Gemini": {"api": "genai", "api_key_file": "~/.token"}}, "ui": {"thinking_color": 123}, } @@ -306,15 +405,56 @@ def test_save_global_config(tmp_path): assert saved == data -def test_get_active_backend(): - assert get_active_backend({"active_backend": "MyBackend"}) == "MyBackend" - assert get_active_backend({}) == DEFAULT_BACKEND - assert get_active_backend(None) == DEFAULT_BACKEND +def test_get_global_config_reads_file_once_and_uses_memory_variable(tmp_path): + from vimini.common import config as common_config + + config_file = tmp_path / "config.json" + data = create_default_global_config(default_model="cached-model") + config_file.write_text(json.dumps(data), encoding="utf-8") + + with ( + patch.object(common_config, "DEFAULT_CONFIG_PATH", str(config_file)), + patch.object(common_config, "_GLOBAL_CONFIG", None), + ): + first = get_global_config() + assert first["backends"]["Gemini"]["default_model"] == "cached-model" + assert common_config._GLOBAL_CONFIG is first + + # Modify file on disk; subsequent get_global_config() calls must return + # the in-memory global variable without re-reading the file. + config_file.write_text("invalid json {{{", encoding="utf-8") + second = get_global_config() + assert second is first + + +def test_get_default_backend(): + assert get_default_backend({"default_backend": "MyBackend"}) == "MyBackend" + # Ignores active_backend + assert ( + get_default_backend( + { + "active_backend": "Ignored", + "default_backend": "ConfiguredDefault", + } + ) + == "ConfiguredDefault" + ) + assert ( + get_default_backend( + { + "active_backend": "Ignored", + "backends": {"Gemini": {}, "Ignored": {}}, + } + ) + == "Gemini" + ) + assert get_default_backend({}) == DEFAULT_BACKEND + assert get_default_backend(None) == DEFAULT_BACKEND -def test_get_active_backend_config(): +def test_get_default_backend_config(): cfg = { - "active_backend": "Proxy", + "default_backend": "Proxy", "backends": { "Proxy": { "api": "openai", @@ -324,19 +464,19 @@ def test_get_active_backend_config(): "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, }, } - name, b_cfg = get_active_backend_config(cfg) + name, b_cfg = get_default_backend_config(cfg) assert name == "Proxy" assert b_cfg["api"] == "openai" - name, b_cfg = get_active_backend_config(None) + name, b_cfg = get_default_backend_config(None) assert name is None assert b_cfg is None -def test_set_active_backend(tmp_path): +def test_set_default_backend(tmp_path): config_file = tmp_path / "config.json" data = { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, "Custom 1": { @@ -348,21 +488,23 @@ def test_set_active_backend(tmp_path): } save_global_config(data, config_path=str(config_file)) - updated = set_active_backend("Custom 1", config_path=str(config_file)) - assert updated["active_backend"] == "Custom 1" + updated = set_default_backend("Custom 1", config_path=str(config_file)) + assert updated["default_backend"] == "Custom 1" + assert "active_backend" not in updated # Verify persisted reloaded = load_global_config(config_path=str(config_file)) - assert reloaded["active_backend"] == "Custom 1" + assert reloaded["default_backend"] == "Custom 1" + assert "active_backend" not in reloaded -def test_set_active_backend_unknown(tmp_path): +def test_set_default_backend_unknown(tmp_path): config_file = tmp_path / "config.json" data = create_default_global_config() save_global_config(data, config_path=str(config_file)) with pytest.raises(ValueError, match="Backend 'NonExistent' not found"): - set_active_backend("NonExistent", config_path=str(config_file)) + set_default_backend("NonExistent", config_path=str(config_file)) def test_load_backend_api_key(tmp_path): diff --git a/tests/test_global_config_ui.py b/tests/test_global_config_ui.py index 7f8c560..e67bdc6 100644 --- a/tests/test_global_config_ui.py +++ b/tests/test_global_config_ui.py @@ -19,7 +19,7 @@ def __init__(self, initial=None, number=1, name=""): def _sample_global_config(): return { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": { "api": "genai", @@ -45,18 +45,104 @@ def test_draw_global_config_listing_contains_backends(): text = "\n".join(lines) assert "Vimini Global Configuration" in text - assert "active_backend = Gemini" in text + assert "default_backend = Gemini" in text + assert "active_backend" not in text assert "> UI PREFERENCES" in text assert "thinking_color = DarkGray (default)" in text assert "* Gemini" in text assert " Proxy" in text assert "base_url = https://proxy.example/v1" in text assert ("ui", "thinking_color") in line_map.values() - assert ("global", "active_backend") in line_map.values() + assert ("global", "default_backend") in line_map.values() assert ("backend", "Proxy") in line_map.values() assert ("backend_field", "Proxy", "default_model") in line_map.values() +def test_anthropic_backend_fields_listed_and_saved_as_ints(): + import vim + from vimini import config + + cfg = { + "default_backend": "Claude", + "backends": { + "Claude": { + "api": "anthropic", + "api_key_file": "~/.config/anthropic.token", + "default_model": "claude-3-7-sonnet-20250219", + } + }, + } + lines, line_map = config._draw_global_config_listing(cfg, "/tmp/vimini/config.json") + text = "\n".join(lines) + assert "max_tokens = (not set)" in text + assert "thinking_budget_tokens = (not set)" in text + assert ("backend_field", "Claude", "max_tokens") in line_map.values() + assert ("backend_field", "Claude", "thinking_budget_tokens") in line_map.values() + + config._VIMINI_PENDING_GLOBAL_CONFIG = copy.deepcopy(cfg) + mock_win = MagicMock() + mock_win.cursor = (1, 0) + mock_win.buffer = MockBuffer() + + with ( + patch.object(vim.current, "window", mock_win), + patch( + "vimini.config._get_global_target_at_cursor", + return_value=("backend_field", "Claude", "max_tokens"), + ), + patch.object(vim, "eval", return_value="8192"), + patch("vimini.config._refresh_global_config_buffer"), + patch("vimini.util.display_message"), + ): + config.edit_global_config_option() + assert ( + config._VIMINI_PENDING_GLOBAL_CONFIG["backends"]["Claude"]["max_tokens"] + == 8192 + ) + assert isinstance( + config._VIMINI_PENDING_GLOBAL_CONFIG["backends"]["Claude"]["max_tokens"], + int, + ) + + +def test_draw_and_delete_legacy_active_backend_in_global_config(): + import vim + from vimini import config + + cfg = _sample_global_config() + cfg["active_backend"] = "Proxy" + + lines, line_map = config._draw_global_config_listing(cfg, "/tmp/vimini/config.json") + text = "\n".join(lines) + + assert "default_backend = Gemini" in text + assert "active_backend = Proxy" in text + assert ("global", "active_backend") in line_map.values() + # Default backend is Gemini, so Gemini has the '*' marker (active_backend is ignored) + assert "* Gemini" in text + assert " Proxy" in text + + config._VIMINI_PENDING_GLOBAL_CONFIG = cfg + mock_win = MagicMock() + mock_win.cursor = (1, 0) + mock_win.buffer = MockBuffer() + + with ( + patch.object(vim.current, "window", mock_win), + patch( + "vimini.config._get_global_target_at_cursor", + return_value=("global", "active_backend"), + ), + patch("vimini.config._refresh_global_config_buffer"), + patch("vimini.util.display_message") as mock_msg, + ): + config.delete_or_clear_global_config_option() + + assert "active_backend" not in config._VIMINI_PENDING_GLOBAL_CONFIG + assert config._VIMINI_PENDING_GLOBAL_CONFIG["default_backend"] == "Gemini" + mock_msg.assert_called_once_with("Deleted 'active_backend' from configuration.") + + def test_global_config_command_buffer_options_and_vars(tmp_path): import vim from vimini import config @@ -73,7 +159,7 @@ def test_global_config_command_buffer_options_and_vars(tmp_path): patch.object(vim.current, "window", mock_win), patch("vimini.util.new_split"), patch("vimini.config.get_global_config_path", return_value=config_path), - patch("vimini.config.load_global_config", return_value=_sample_global_config()), + patch("vimini.config.get_global_config", return_value=_sample_global_config()), ): vim.command.reset_mock() config.global_config_command() @@ -86,18 +172,20 @@ def test_global_config_command_buffer_options_and_vars(tmp_path): assert any("Vimini Global Configuration" in line for line in mock_buf) -def test_apply_backend_config_updates_runtime(): +def test_apply_backend_config_updates_runtime_only(): import vim from vimini import main, util cfg = _sample_global_config() with ( - patch("vimini.common.config.set_active_backend") as mock_set_active, + patch("vimini.common.config.save_global_config") as mock_save, patch("vimini.main.send_setup") as mock_send_setup, ): - main.apply_backend_config(cfg, "Proxy", persist=True) + main.apply_backend_config(cfg, "Proxy") - mock_set_active.assert_called_once_with("Proxy") + mock_save.assert_not_called() + assert "active_backend" not in cfg + assert cfg["default_backend"] == "Gemini" assert util._BACKEND == "Proxy" assert util._MODEL == "gpt-4o" assert util._API_KEY_FILE == os.path.expanduser("~/.config/proxy.token") @@ -212,7 +300,7 @@ def test_delete_backend_option_cannot_delete_last(): from vimini import config single_backend_cfg = { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": { "api": "genai", @@ -249,7 +337,7 @@ def test_confirm_global_config_dialog_confirmed(): orig = _sample_global_config() pending = copy.deepcopy(orig) - pending["active_backend"] = "Proxy" + pending["default_backend"] = "Proxy" config._VIMINI_ORIGINAL_GLOBAL_CONFIG = orig config._VIMINI_PENDING_GLOBAL_CONFIG = pending diff --git a/tests/test_models.py b/tests/test_models.py index 0cd6bcf..b87b485 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -30,9 +30,9 @@ @pytest.fixture def genai_global_config(): """Isolate tests from the developer's real global vimini config.""" - with patch( - "vimini.common.config.load_global_config", return_value=GENAI_GLOBAL_CONFIG - ): + from vimini.common import config as common_config + + with patch.object(common_config, "_GLOBAL_CONFIG", GENAI_GLOBAL_CONFIG): yield @@ -310,7 +310,7 @@ def test_set_default_model_persists_and_switches(genai_global_config): with ( patch.object(vim.current, "buffer", mock_buffer), - patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.common.config.get_global_config", return_value=config), patch("vimini.common.config.save_global_config") as mock_save_config, patch("vimini.main.send_setup") as mock_setup, patch("vimini.util.display_message") as mock_display, diff --git a/tests/test_util_buffer.py b/tests/test_util_buffer.py index 759fa66..c3ad511 100644 --- a/tests/test_util_buffer.py +++ b/tests/test_util_buffer.py @@ -209,7 +209,7 @@ def test_get_model_name_non_gemini_backend_does_not_call_get_client(): } with ( - patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.common.config.get_global_config", return_value=config), patch("vimini.util.get_client") as mock_get_client, ): name = util.get_model_name() @@ -238,7 +238,7 @@ def test_get_model_name_custom_genai_backend_calls_client(): mock_client.models.get.return_value = mock_info with ( - patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.common.config.get_global_config", return_value=config), patch("vimini.util.get_client", return_value=mock_client), ): assert util.get_model_name() == "Work Gemini Display" diff --git a/tests/test_vim_backend_ui.py b/tests/test_vim_backend_ui.py index 5a2162d..f49ba58 100644 --- a/tests/test_vim_backend_ui.py +++ b/tests/test_vim_backend_ui.py @@ -13,8 +13,9 @@ def test_backend_command_without_arguments(): + util._BACKEND = "Gemini" config = { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, "Custom 1": { @@ -31,25 +32,29 @@ def test_backend_command_without_arguments(): } with ( - patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.common.config._GLOBAL_CONFIG", config), + patch("vimini.common.config.load_global_config") as mock_load, patch("vimini.util.display_message") as mock_display, ): main.backend_command(None) mock_display.assert_called_once() + mock_load.assert_not_called() msg = mock_display.call_args[0][0] assert "Gemini (available: Custom 1, Custom 2)" in msg def test_backend_command_without_arguments_no_others(): + util._BACKEND = "Gemini" config = { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, }, } with ( - patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.common.config._GLOBAL_CONFIG", config), + patch("vimini.common.config.load_global_config") as mock_load, patch("vimini.util.display_message") as mock_display, ): main.backend_command("") @@ -59,15 +64,17 @@ def test_backend_command_without_arguments_no_others(): def test_backend_command_without_arguments_whitespace(): + util._BACKEND = "Gemini" config = { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, }, } with ( - patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.common.config._GLOBAL_CONFIG", config), + patch("vimini.common.config.load_global_config") as mock_load, patch("vimini.util.display_message") as mock_display, ): main.backend_command(" ") @@ -76,9 +83,10 @@ def test_backend_command_without_arguments_whitespace(): assert "Gemini (no other backends configured)" in msg -def test_backend_command_switch_success(): +def test_backend_command_switch_success_does_not_persist_to_config(): + util._BACKEND = "Gemini" config = { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": { "api": "genai", @@ -94,14 +102,18 @@ def test_backend_command_switch_success(): } with ( - patch("vimini.common.config.load_global_config", return_value=config), - patch("vimini.common.config.set_active_backend") as mock_set_active, + patch("vimini.common.config._GLOBAL_CONFIG", config), + patch("vimini.common.config.load_global_config") as mock_load, + patch("vimini.common.config.save_global_config") as mock_save, patch("vimini.main.send_setup") as mock_send_setup, patch("vimini.util.display_message") as mock_display, ): main.backend_command("Custom 1") - mock_set_active.assert_called_once_with("Custom 1") + mock_load.assert_not_called() + mock_save.assert_not_called() + assert config["default_backend"] == "Gemini" + assert "active_backend" not in config assert util._BACKEND == "Custom 1" assert util._MODEL == "gpt-4o" assert util._API_KEY_FILE == os.path.expanduser("~/.config/proxy.token") @@ -113,10 +125,16 @@ def test_backend_command_switch_success(): in mock_display.call_args[0][0] ) + # Calling :ViminiBackend without arguments now reports the runtime active backend + mock_display.reset_mock() + main.backend_command(None) + mock_display.assert_called_once() + assert "Custom 1 (available: Gemini)" in mock_display.call_args[0][0] + def test_backend_command_switch_resets_key_and_model_when_missing(): config = { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": { "api": "genai", @@ -138,14 +156,16 @@ def test_backend_command_switch_resets_key_and_model_when_missing(): util._GENAI_CLIENT = object() with ( - patch("vimini.common.config.load_global_config", return_value=config), - patch("vimini.common.config.set_active_backend") as mock_set_active, + patch("vimini.common.config._GLOBAL_CONFIG", config), + patch("vimini.common.config.load_global_config") as mock_load, + patch("vimini.common.config.save_global_config") as mock_save, patch("vimini.main.send_setup") as mock_send_setup, patch("vimini.util.display_message") as mock_display, ): main.backend_command("Local") - mock_set_active.assert_called_once_with("Local") + mock_load.assert_not_called() + mock_save.assert_not_called() assert util._BACKEND == "Local" assert util._MODEL == "llama3" assert util._MODEL_NAME is None @@ -157,14 +177,14 @@ def test_backend_command_switch_resets_key_and_model_when_missing(): def test_backend_command_switch_invalid(): config = { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, }, } with ( - patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.common.config._GLOBAL_CONFIG", config), patch("vimini.util.display_message") as mock_display, ): main.backend_command("NonExistent") @@ -175,7 +195,7 @@ def test_backend_command_switch_invalid(): def test_get_available_backends(): config = { - "active_backend": "Gemini", + "default_backend": "Gemini", "backends": { "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, "Custom 1": { @@ -191,12 +211,16 @@ def test_get_available_backends(): }, } - with patch("vimini.common.config.load_global_config", return_value=config): + with ( + patch("vimini.common.config._GLOBAL_CONFIG", config), + patch("vimini.common.config.load_global_config") as mock_load, + ): all_b = main.get_available_backends() assert all_b == ["Gemini", "Custom 1", "Custom 2"] cust = main.get_available_backends("cust") assert cust == ["Custom 1", "Custom 2"] + mock_load.assert_not_called() def test_build_models_buffer_openai_single_group(): @@ -242,15 +266,21 @@ def test_build_models_buffer_genai_categories(): assert any("Only 'Multimodal & Text Generation'" in line for line in buffer_lines) -def test_initialize_uses_global_config_model_not_vim_config(): +def test_initialize_uses_global_config_default_backend_and_ignores_active_backend(): config = { - "active_backend": "Gemini", + "default_backend": "Gemini", + "active_backend": "OtherBackend", "backends": { "Gemini": { "api": "genai", "api_key_file": "~/.config/gemini.token", "default_model": "gemini-global-default", - } + }, + "OtherBackend": { + "api": "openai", + "api_key_file": "~/.config/other.token", + "default_model": "other-model", + }, }, } @@ -268,6 +298,8 @@ def test_initialize_uses_global_config_model_not_vim_config(): assert util._BACKEND == "Gemini" assert util._MODEL == "gemini-global-default" assert util._API_KEY_FILE == os.path.expanduser("~/.config/gemini.token") + # active_backend is not removed from config + assert config["active_backend"] == "OtherBackend" def test_send_setup_does_not_send_api_key_file(): @@ -310,7 +342,7 @@ def test_initialize_reports_invalid_global_config(): def test_initialize_does_not_warn_for_keyless_openai_backend(): config = { - "active_backend": "Local", + "default_backend": "Local", "backends": { "Local": { "api": "openai", @@ -340,7 +372,7 @@ def test_initialize_does_not_warn_for_keyless_openai_backend(): def test_initialize_warns_with_backend_specific_api_key_message(): config = { - "active_backend": "Remote", + "default_backend": "Remote", "backends": { "Remote": { "api": "openai", @@ -367,7 +399,7 @@ def test_initialize_warns_with_backend_specific_api_key_message(): def test_initialize_warns_when_api_key_file_specified_without_requires_api_key(): config = { - "active_backend": "Remote", + "default_backend": "Remote", "backends": { "Remote": { "api": "openai",