From 692ef194307a9621f8c0e6d6a7f443889c90aaa4 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 19:00:02 -0400 Subject: [PATCH 01/19] Add global configuration and ProxyAPI plan Replace the ACP architectural documents with an integration plan for supporting OpenAI-compatible backends alongside Google Gemini. Implement global configuration management in ~/.var/vimini/config.json to support multiple provider backends, auto-generate default settings, and facilitate runtime backend switching. Add corresponding unit tests and update the README documentation. Assisted-by: Gemini:Gemini 3.8 Flash Signed-off-by: Simo Sorce --- ACP.md | 75 ----------- NEW-ARCHITECTURE.md | 85 ------------- PROXY-API.md | 141 +++++++++++++++++++++ README.md | 28 +++++ python3/vimini/common/config.py | 203 ++++++++++++++++++++++++++++++ tests/test_global_config.py | 217 ++++++++++++++++++++++++++++++++ 6 files changed, 589 insertions(+), 160 deletions(-) delete mode 100644 ACP.md delete mode 100644 NEW-ARCHITECTURE.md create mode 100644 PROXY-API.md create mode 100644 python3/vimini/common/config.py create mode 100644 tests/test_global_config.py diff --git a/ACP.md b/ACP.md deleted file mode 100644 index 5b27bc2..0000000 --- a/ACP.md +++ /dev/null @@ -1,75 +0,0 @@ -# Introduction - -> Get started with the Agent Client Protocol. - -The Agent Client Protocol (ACP) standardizes communication between code editors/IDEs and coding agents and is suitable for both local and remote scenarios. - -## Why ACP? - -AI coding agents and editors are tightly coupled but interoperability isn't the default. Each editor must build custom integrations for every agent they want to support, and agents must implement editor-specific APIs to reach users. -This creates several problems: - -* Integration overhead: Every new agent-editor combination requires custom work -* Limited compatibility: Agents work with only a subset of available editors -* Developer lock-in: Choosing an agent often means accepting their available interfaces - -ACP solves this by providing a standardized protocol for agent-editor communication, similar to how the [Language Server Protocol (LSP)](https://microsoft.github.io/language-server-protocol/) standardized language server integration. - -Agents that implement ACP work with any compatible editor. Editors that support ACP gain access to the entire ecosystem of ACP-compatible agents. -This decoupling allows both sides to innovate independently while giving developers the freedom to choose the best tools for their workflow. - -## Overview - -ACP assumes that the user is primarily in their editor, and wants to reach out and use agents to assist them with specific tasks. - -ACP is suitable for both local and remote scenarios: - -* **Local agents** run as sub-processes of the code editor, communicating via JSON-RPC over stdio. -* **Remote agents** can be hosted in the cloud or on separate infrastructure, communicating over HTTP or WebSocket - - - Full support for remote agents is a work in progress. We are actively - collaborating with agentic platforms to ensure the protocol addresses the - specific requirements of cloud-hosted and remote deployment scenarios. - - -The protocol re-uses the JSON representations used in MCP where possible, but includes custom types for useful agentic coding UX elements, like displaying diffs. - -The default format for user-readable text is Markdown, which allows enough flexibility to represent rich formatting without requiring that the code editor is capable of rendering HTML. - - -# Architecture - -> Overview of the Agent Client Protocol architecture. - -The Agent Client Protocol defines a standard interface for communication between AI agents and client applications. The architecture is designed to be flexible, extensible, and platform-agnostic. - -## Design Philosophy - -The protocol architecture follows several key principles: - -1. **MCP-friendly**: The protocol is built on JSON-RPC, and re-uses MCP types where possible so that integrators don't need to build yet-another representation for common data types. -2. **UX-first**: It is designed to solve the UX challenges of interacting with AI agents; ensuring there's enough flexibility to render clearly the agents intent, but is no more abstract than it needs to be. -3. **Trusted**: ACP works when you're using a code editor to talk to a model you trust. You still have controls over the agent's tool calls, but the code editor gives the agent access to local files and MCP servers. - -## Setup - -When the user tries to connect to an agent, the editor boots the agent sub-process on demand, and all communication happens over stdin/stdout. - -Each connection can support several concurrent sessions, so you can have multiple trains of thought going on at once. - -Server Client setup - -ACP makes heavy use of JSON-RPC notifications to allow the agent to stream updates to the UI in real-time. It also uses JSON-RPC's bidirectional requests to allow the agent to make requests of the code editor: for example to request permissions for a tool call. - -## MCP - -Commonly the code editor will have user-configured MCP servers. When forwarding the prompt from the user, it passes configuration for these to the agent. This allows the agent to connect directly to the MCP server. - -MCP Server connection - -The code editor may itself also wish to export MCP based tools. Instead of trying to run MCP and ACP on the same socket, the code editor can provide its own MCP server as configuration. As agents may only support MCP over stdio, the code editor can provide a small proxy that tunnels requests back to itself: - -MCP connection to self - - diff --git a/NEW-ARCHITECTURE.md b/NEW-ARCHITECTURE.md deleted file mode 100644 index 0a22dfc..0000000 --- a/NEW-ARCHITECTURE.md +++ /dev/null @@ -1,85 +0,0 @@ -# Vimini Next-Generation Architecture (ACP Aligned) - -This document outlines the proposed transition of Vimini from a monolithic embedded Python plugin to a 2-component architecture based on the **Agent Client Protocol (ACP)**. This split will improve performance, stability, and isolation by moving the heavy AI logic and state management out of the Vim process, while standardizing how Vimini interacts with coding agents. - -## 1. Architecture Overview - -The new architecture consists of two main components communicating via ACP: - -1. **Vimini Client (Vim Plugin)** - - A thin Vimscript/Python layer living inside the Vim process. - - Acts as an **ACP Client**, responsible strictly for UI interactions (buffers, popups, key mappings, diff rendering) and Vim state extraction. - - Boots the agent sub-process on demand and communicates via JSON-RPC over `stdio`. - -2. **Vimini Server (ACP Agent / External Daemon Process)** - - A standalone Python process hosting the Google GenAI client, background threads, and state management. - - Runs as a local agent sub-process of the code editor. - - Handles all API rate limiting, long-running LLM requests, and context building. - -## 2. Component Breakdown - -### 2.1 The Thin Client Layer (Editor) -- **No blocking AI calls:** The embedded `py3` layer will no longer import `google.genai` or manage threads. -- **Sub-process Management:** When the user initiates a session, the editor boots the agent sub-process. Multiple concurrent sessions can be supported over the connection. -- **Asynchronous IPC:** Uses Vim's `channel` and `job` features (or Python's `asyncio`/`subprocess` within `py3`) to send JSON-RPC messages via `stdio`. -- **MCP Proxying:** The client can expose Vim-specific tools (like reading buffers or quickfix lists) by providing a small proxy that tunnels MCP requests back to itself. - -### 2.2 The External Daemon Process (Agent) -- **ACP-Compliant Agent:** Runs entirely independently of the Vim main loop, communicating exclusively over `stdin/stdout` using the ACP standard. -- **MCP Integration:** Receives configuration for user-configured MCP servers from the client. The agent connects directly to these MCP servers to extend its capabilities. -- **Bidirectional Communication:** Can push real-time UX updates (like streaming chunks or UI elements) and make bidirectional requests to the client (e.g., requesting user permissions for tool calls). - -## 3. Communication Protocol (Agent Client Protocol) -We will adopt the **Agent Client Protocol (ACP)**, replacing the previously proposed custom socket protocol. - -- **JSON-RPC over Stdio:** Reliable, standardized communication. -- **MCP Type Re-use:** ACP re-uses Model Context Protocol (MCP) JSON representations where possible, minimizing the need to build custom data types. -- **UX-First:** Includes custom types for agentic coding UX elements (e.g., displaying diffs in markdown format without requiring HTML rendering capabilities in Vim). - -**Client -> Server (User Prompt / Request)** -```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "agent/chat", - "params": { - "messages": [{"role": "user", "content": "Refactor this function"}], - "mcp_servers": { ... } - } -} -``` - -**Server -> Client (Streaming UX / Tool Notification)** -```json -{ - "jsonrpc": "2.0", - "method": "window/logMessage", - "params": { - "type": 4, - "message": "Applying diff to src/main.py..." - } -} -``` - -## 4. MCP Integration Strategy - -By adopting ACP, Vimini will natively support the Model Context Protocol (MCP): -1. **External MCP Servers:** The user configures external MCP servers (e.g., GitHub, local databases). The Vimini client passes this configuration to the Vimini agent, which connects to them directly. -2. **Vim-Native MCP Server:** The Vimini client will export its own MCP-based tools (like `read_vim_buffer` or `apply_patch`). The client acts as a small proxy, passing its self-hosted MCP configuration to the agent, allowing the agent to request actions from Vim seamlessly. - -## 5. Migration Plan - -1. **Phase 1: Abstract API Calls** - - Refactor `vimini/util.py` and `vimini/main.py` to route all AI requests through a unified messaging interface. -2. **Phase 2: Build the ACP Agent Daemon** - - Create an `agent.py` entry point that implements a JSON-RPC server over `stdio` following the ACP specification, loading the existing Vimini Python logic. -3. **Phase 3: Implement ACP Client in Vimini** - - Replace the embedded Python threading with a sub-process spawner that sends payloads and polls for responses via `stdio`. -4. **Phase 4: MCP Tooling Integration** - - Build the internal MCP proxy in the Vimini client to expose editor context to the agent, deprecating bespoke context-gathering functions. - -## 6. Benefits of ACP Adoption -- **Ecosystem Interoperability:** By implementing ACP, the Vimini agent could potentially be used by other ACP-compatible editors, and the Vimini client could connect to other ACP-compatible agents. -- **Zero Blocking:** Completely eliminates Vim UI freezes during complex context gathering or API initialization. -- **Standardized Tooling:** MCP compatibility immediately grants Vimini access to a vast ecosystem of context providers without writing custom extensions. -- **Security & Trust:** The editor retains control, booting the agent in a trusted environment and explicitly granting it access to local files and specific MCP servers. diff --git a/PROXY-API.md b/PROXY-API.md new file mode 100644 index 0000000..312d62b --- /dev/null +++ b/PROXY-API.md @@ -0,0 +1,141 @@ +# ProxyAPI Integration Plan + +This document outlines the architectural plan for adding support for OpenAI-compatible APIs (**ProxyAPI**) alongside the default Google Gemini (**GenAI**) backend in Vimini. + +--- + +## 1. Overview & Goals + +- **Dual Backend Architecture**: Support both the default Google Gemini backend (`genai`) and an OpenAI-compatible proxy or model provider backend (`ProxyAPI`). +- **De Facto Standard Compatibility**: Enable compatibility with standard OpenAI API endpoints (such as OpenAI, OpenRouter, LiteLLM, Ollama, vLLM, and LocalAI). Anthropic protocol support is left as a future extension. +- **Persistent Configuration**: Store endpoints, API key file paths, default models, and custom headers in `~/.var/vimini/config.json`. +- **Automatic Default Generation**: If `~/.var/vimini/config.json` does not exist, automatically generate a v1 configuration file based on current settings and behavior. +- **Dynamic Runtime Switching**: Allow switching between backends at runtime via `:ViminiBackend` without restarting Vim or the background agent server. +- **Zero Additional Dependencies**: Implement the ProxyAPI HTTP and streaming communication using Python's standard library (`urllib.request` / `http.client`, `json`, `ssl`), adhering to Vimini's guideline of avoiding third-party dependencies outside `google-genai`. + +--- + +## 2. Configuration & Credential Management + +Implemented and Documented elsewhere + +--- + +## 3. Backend Abstraction Layer + +A modular backend architecture isolates provider-specific communication behind a unified interface: + +``` +python3/vimini/common/ +├── backends/ +│ ├── __init__.py # Backend registry and factory: get_backend(name, config) +│ ├── base.py # Abstract interfaces: BaseClient, BaseChatSession, StreamChunk +│ ├── genai_backend.py # Google Gemini implementation (using google-genai) +│ └── proxy_backend.py # OpenAI-compatible implementation (using standard library HTTP/SSE) +``` + +### 3.1 Core Abstraction (`base.py`) + +- **`BaseClient`**: + - `list_models() -> List[Dict]`: Returns standardized model records (`name`, `display_name`, `description`, `supported_actions`). + - `generate_content(prompt, model, system_instruction=None, config=None) -> str`: Non-streaming generation for commit messages, autocomplete, etc. + - `generate_content_stream(prompt, model, system_instruction=None, config=None) -> Iterator[StreamChunk]`: Streaming generation for code generation. + - `create_chat(model, system_instruction, tools, history=None) -> BaseChatSession`: Interactive multi-turn chat sessions. + +- **`BaseChatSession`**: + - `send_message_stream(prompt_or_tool_responses) -> Iterator[StreamChunk]`: Sends prompts or tool results and yields chunks. + - `has_history() -> bool`: Returns whether the backend supports history retrieval. For `proxy_backend` (`openai`), this returns `False`. + - `get_history() -> List[Dict]`: Retrieves serializable chat history if supported (`has_history() == True`). Callers will check `has_history()` before attempting history operations. + +- **`StreamChunk`**: + - `text`: Incremental generation text. + - `thought`: Incremental reasoning/thinking content (supporting DeepSeek-R1 `` delimiters and OpenAI `reasoning_content` deltas). + - `tool_calls`: List of requested tool invocations (`name`, `args`, `call_id`). + +--- + +## 4. ProxyAPI Implementation Details + +### 4.1 Standard Library HTTP & SSE Client +- Build an SSE (Server-Sent Events) parser over `urllib.request` / `http.client`. +- Stream response deltas line by line (`data: { ... }`) to yield real-time text and thoughts to Vim. + +### 4.2 Tool & Function Calling Translation +- **Definitions**: Translate Vimini tool declarations (`apply_patch`, `read_file`, `list_directory`, `list_git_commits`, `get_git_commit`, `git_grep`, `build_code`, `test_code`, `fix_format`) into OpenAI `tools` schema format: + ```json + { + "type": "function", + "function": { + "name": "read_file", + "description": "...", + "parameters": { ... } + } + } + ``` +- **Responses**: Reconstruct tool calls from streaming `tool_calls` chunks, prompt user in Vim, and return results to the model using `{ "role": "tool", "tool_call_id": id, "content": result }`. + +--- + +## 5. Runtime Backend Switching & Vim UI + +### 5.1 Vimscript Commands & Options (`plugin/vimini.vim`) +- **Global Variable**: `g:vimini_backend` (the name of the active backend, e.g. `'Gemini'`, `'Custom 1'`). +- **Command**: `:ViminiBackend [backend_name]` + - **Without arguments**: Echoes the active backend and lists other available backends in parentheses. + - Example: `Gemini (available: Custom 1, Custom 2)` + - **With argument**: Validates that the requested backend exists in `~/.var/vimini/config.json`. If valid: + - Sets active backend in runtime and config. + - Updates `g:vimini_backend`. + - Updates `g:vimini_model` to the new backend's `default_model`. + - Sends an updated `setup` message to the agent server. + +### 5.2 Model List & Status Integration +- `:ViminiListModels`: + - For `genai` backends, models are categorized by supported actions (Multimodal, Embeddings, Image, etc.). + - For `openai` (ProxyAPI) backends, all models returned by `GET /v1/models` are listed under a single group: `[Available Models]`. + - Displays the active backend name in the buffer header. +- `:ViminiStatus`: Displays active backend name, protocol type, endpoint URL, and current model. + +--- + +## 6. Agent Server & IPC Protocol Updates + +### 6.1 Setup Message Payload +Update `send_setup()` in `main.py` to transmit the active backend name: + +```json +{ + "jsonrpc": "2.0", + "id": "setup", + "method": "setup", + "params": { + "backend": "Custom 1" + } +} +``` + +Only the selected backend name is sent; the agent server reads `~/.var/vimini/config.json` to load the corresponding backend configuration options. + +### 6.2 Agent Server Handlers +- Update `execute_function` in `server.py` to route `list_models`, `commit`, and `autocomplete` through the active backend client. +- Update `ChatSession` in `agent/chat.py`, `CodeSession` in `agent/code.py`, and `ReviewSession` in `agent/review.py` to obtain their client and session via `get_backend(agent_config)`. +- Update chat session history callers to check `has_history()` and bypass history handling if false. + +--- + +## 7. Implementation Roadmap + +1. **Phase 1: Config Management**: [COMPLETED] Implement configuration loader and automatic v1 default generator for `~/.var/vimini/config.json`. +2. **Phase 2: Abstraction & GenAI Adapter**: Refactor Google GenAI code into `GenAIBackend` matching `BaseClient` / `BaseChatSession` and adding `has_history()`. +3. **Phase 3: ProxyAPI Client**: Implement standard-library OpenAI client (`urllib.request`) supporting models listing, streaming completions, and tool calling. +4. **Phase 4: Agent Server & Session Integration**: Update `server.py`, `chat.py`, `code.py`, and `review.py` to use backend factory and honor `has_history()`. +5. **Phase 5: Vim Frontend & Commands**: Implement `:ViminiBackend` (with status echo and argument switching), single-group OpenAI model listing in `:ViminiListModels`, `:ViminiStatus`, and help documentation. +6. **Phase 6: Testing & Validation**: Add unit tests for config generation/parsing, backend switching, ProxyAPI mock requests, and single-group model listing. + +## 8. Implementation strategy + +As implementation progresses, phases are marked as complete, and information is +moved to the README file in concise form and only as necessary, information that +is no longer relevant because a phase or feature is already implemented is +simply removed and the section content is replaced with the standard text +"Implemented and Documented elsewhere" diff --git a/README.md b/README.md index 9f9d9a0..06692d8 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,34 @@ customizations. } ``` +11. **Global & Backend Configuration (`~/.var/vimini/config.json`)**: + 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", + "backends": { + "Gemini": { + "api": "genai", + "api_key_file": "~/.config/gemini.token", + "default_model": "gemini-3.8-flash" + } + } + } + ``` + Configure per-project settings such as whether compilation is needed, shell commands for compiling/building code and running tests when invoked by chat tools (`build_code` and `test_code`), and the directory path where temporary git worktrees can be created. Project configuration is stored in `~/.var/vimini/projects/`: + ```json + { + "version": "0.1", + "configuration": { + "build-command": "make -j$(nproc)", + "test-command": "pytest", + "compilation-needed": false, + "worktree": "~/.var/vimini/worktree" + }, + "files": [] + } + ``` + ## Usage Vimini exposes several commands for interacting with the Gemini API: diff --git a/python3/vimini/common/config.py b/python3/vimini/common/config.py new file mode 100644 index 0000000..70d6d3d --- /dev/null +++ b/python3/vimini/common/config.py @@ -0,0 +1,203 @@ +import os +import json +import logging + +logger = logging.getLogger("vimini_agent") + +DEFAULT_CONFIG_DIR = os.path.expanduser("~/.var/vimini") +DEFAULT_CONFIG_PATH = os.path.join(DEFAULT_CONFIG_DIR, "config.json") +DEFAULT_BACKEND = "Gemini" +DEFAULT_MODEL = "gemini-3.8-flash" +DEFAULT_GEMINI_KEY_FILE = "~/.config/gemini.token" + + +def get_global_config_path(): + """ + Returns the path to the global configuration file ~/.var/vimini/config.json. + """ + return DEFAULT_CONFIG_PATH + + +def create_default_global_config(default_model=None, api_key_file=None): + """ + Generates a default v1 configuration dictionary. + """ + return { + "active_backend": DEFAULT_BACKEND, + "backends": { + DEFAULT_BACKEND: { + "api": "genai", + "api_key_file": api_key_file or DEFAULT_GEMINI_KEY_FILE, + "default_model": default_model or DEFAULT_MODEL, + } + }, + } + + +def validate_global_config(config): + """ + Validates the structure of global configuration data. + Raises ValueError if invalid. + """ + if not isinstance(config, dict): + raise ValueError( + 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'" + ) + + for name, backend_info in backends.items(): + if not isinstance(backend_info, dict): + raise ValueError(f"Backend configuration for '{name}' must be a dictionary") + api = backend_info.get("api") + if not isinstance(api, str) or not api.strip(): + raise ValueError(f"Backend '{name}' must specify a non-empty 'api' type") + key_file = backend_info.get("api_key_file") + if key_file is not None and not isinstance(key_file, str): + raise ValueError( + f"Backend '{name}' 'api_key_file' must be a string path if specified" + ) + + return config + + +def load_global_config(config_path=None, default_model=None, api_key_file=None): + """ + Loads configuration from ~/.var/vimini/config.json. + If the file does not exist, generates a default v1 config, saves it, and returns it. + """ + target_path = ( + os.path.realpath(os.path.expanduser(config_path)) + if config_path + else DEFAULT_CONFIG_PATH + ) + + if not os.path.exists(target_path): + default_config = create_default_global_config( + default_model=default_model, api_key_file=api_key_file + ) + save_global_config(default_config, config_path=target_path) + return default_config + + try: + with open(target_path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + raise ValueError( + f"Failed to parse global configuration file '{target_path}': {e}" + ) + + return validate_global_config(data) + + +def save_global_config(config, config_path=None): + """ + Saves the global configuration dictionary to ~/.var/vimini/config.json. + """ + target_path = ( + os.path.realpath(os.path.expanduser(config_path)) + if config_path + else DEFAULT_CONFIG_PATH + ) + + validate_global_config(config) + + os.makedirs(os.path.dirname(target_path), exist_ok=True) + temp_target = f"{target_path}.tmp.{os.getpid()}" + try: + with open(temp_target, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + f.write("\n") + os.replace(temp_target, target_path) + finally: + if os.path.exists(temp_target): + try: + os.remove(temp_target) + except OSError: + pass + + return True + + +def get_active_backend(config): + """ + Returns the name of the active backend. + """ + if isinstance(config, dict): + return config.get("active_backend", DEFAULT_BACKEND) + return DEFAULT_BACKEND + + +def get_active_backend_config(config): + """ + Returns a tuple (backend_name, backend_config_dict) for the active backend. + """ + if not isinstance(config, dict): + return None, None + name = get_active_backend(config) + backends = config.get("backends", {}) + return name, backends.get(name) + + +def set_active_backend(backend_name, config=None, config_path=None): + """ + Switches the active backend in the configuration and saves the changes. + """ + target_path = ( + os.path.realpath(os.path.expanduser(config_path)) + if config_path + else DEFAULT_CONFIG_PATH + ) + + if config is None: + config = load_global_config(config_path=target_path) + + backends = config.get("backends", {}) + if backend_name not in backends: + available = list(backends.keys()) + raise ValueError( + f"Backend '{backend_name}' not found. Available backends: {available}" + ) + + config["active_backend"] = backend_name + save_global_config(config, config_path=target_path) + return config + + +def load_backend_api_key(backend_config): + """ + Loads API key for a backend from its configured api_key_file. + """ + if not isinstance(backend_config, dict): + return None + + api_key_file = backend_config.get("api_key_file") + if not api_key_file: + return None + + expanded_path = os.path.expanduser(api_key_file) + if os.path.exists(expanded_path): + try: + with open(expanded_path, "r", encoding="utf-8") as f: + content = f.read().strip() + return content if content else None + except Exception as e: + logger.error(f"Error reading API key file '{expanded_path}': {e}") + raise RuntimeError(f"Error reading API key file '{expanded_path}': {e}") + + return None diff --git a/tests/test_global_config.py b/tests/test_global_config.py new file mode 100644 index 0000000..0f906d0 --- /dev/null +++ b/tests/test_global_config.py @@ -0,0 +1,217 @@ +import json +import sys +import os +import pytest +from unittest.mock import patch, mock_open + +sys.path.insert( + 0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "python3")) +) + +from vimini.common.config import ( + DEFAULT_BACKEND, + DEFAULT_MODEL, + DEFAULT_GEMINI_KEY_FILE, + create_default_global_config, + validate_global_config, + load_global_config, + save_global_config, + get_active_backend, + get_active_backend_config, + set_active_backend, + load_backend_api_key, +) + + +def test_create_default_global_config(): + cfg = create_default_global_config() + assert cfg["active_backend"] == DEFAULT_BACKEND + assert DEFAULT_BACKEND in cfg["backends"] + gemini = cfg["backends"][DEFAULT_BACKEND] + assert gemini["api"] == "genai" + assert gemini["api_key_file"] == DEFAULT_GEMINI_KEY_FILE + assert gemini["default_model"] == DEFAULT_MODEL + + +def test_create_default_global_config_custom_args(): + cfg = create_default_global_config( + default_model="custom-model", api_key_file="/path/to/key" + ) + assert cfg["backends"][DEFAULT_BACKEND]["default_model"] == "custom-model" + assert cfg["backends"][DEFAULT_BACKEND]["api_key_file"] == "/path/to/key" + + +def test_load_global_config_creates_default_when_missing(tmp_path): + config_file = tmp_path / "config.json" + assert not config_file.exists() + + cfg = load_global_config(config_path=str(config_file), default_model="gemini-test") + assert config_file.exists() + assert cfg["active_backend"] == "Gemini" + assert cfg["backends"]["Gemini"]["default_model"] == "gemini-test" + + # Verify content written to disk + with open(config_file, "r", encoding="utf-8") as f: + disk_data = json.load(f) + assert disk_data == cfg + + +def test_load_global_config_reads_existing(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/v1", + "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)) + assert cfg["active_backend"] == "Custom 1" + assert "Custom 1" in cfg["backends"] + + +def test_load_global_config_invalid_json(tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text("invalid json {{{", encoding="utf-8") + + with pytest.raises(ValueError, match="Failed to parse global configuration"): + load_global_config(config_path=str(config_file)) + + +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": {}}) + + with pytest.raises(ValueError, match="not defined in 'backends'"): + validate_global_config( + { + "active_backend": "Unknown", + "backends": {"Gemini": {"api": "genai"}}, + } + ) + + with pytest.raises(ValueError, match="must be a dictionary"): + validate_global_config( + { + "active_backend": "Gemini", + "backends": {"Gemini": "not a dict"}, + } + ) + + with pytest.raises(ValueError, match="must specify a non-empty 'api' type"): + validate_global_config( + { + "active_backend": "Gemini", + "backends": {"Gemini": {"api": ""}}, + } + ) + + with pytest.raises(ValueError, match="'api_key_file' must be a string path"): + validate_global_config( + { + "active_backend": "Gemini", + "backends": {"Gemini": {"api": "genai", "api_key_file": 123}}, + } + ) + + +def test_save_global_config(tmp_path): + config_file = tmp_path / "subdir" / "config.json" + data = create_default_global_config() + + res = save_global_config(data, config_path=str(config_file)) + assert res is True + assert config_file.exists() + + with open(config_file, "r", encoding="utf-8") as f: + saved = json.load(f) + 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_active_backend_config(): + cfg = { + "active_backend": "Proxy", + "backends": { + "Proxy": {"api": "openai", "default_model": "gpt-4o"}, + "Gemini": {"api": "genai"}, + }, + } + name, b_cfg = get_active_backend_config(cfg) + assert name == "Proxy" + assert b_cfg["api"] == "openai" + + name, b_cfg = get_active_backend_config(None) + assert name is None + assert b_cfg is None + + +def test_set_active_backend(tmp_path): + config_file = tmp_path / "config.json" + data = { + "active_backend": "Gemini", + "backends": { + "Gemini": {"api": "genai"}, + "Custom 1": {"api": "openai"}, + }, + } + 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" + + # Verify persisted + reloaded = load_global_config(config_path=str(config_file)) + assert reloaded["active_backend"] == "Custom 1" + + +def test_set_active_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)) + + +def test_load_backend_api_key(tmp_path): + key_file = tmp_path / "key.token" + key_file.write_text(" my-secret-key-123\n", encoding="utf-8") + + backend_cfg = {"api": "openai", "api_key_file": str(key_file)} + key = load_backend_api_key(backend_cfg) + assert key == "my-secret-key-123" + + +def test_load_backend_api_key_empty_or_missing(tmp_path): + assert load_backend_api_key({}) is None + assert load_backend_api_key({"api_key_file": None}) is None + + missing_file = tmp_path / "nonexistent.token" + assert load_backend_api_key({"api_key_file": str(missing_file)}) is None + + empty_file = tmp_path / "empty.token" + empty_file.write_text(" \n", encoding="utf-8") + assert load_backend_api_key({"api_key_file": str(empty_file)}) is None From d816d078e36a7ed19015c7ff6d10ceb0b5af9821 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 19:28:57 -0400 Subject: [PATCH 02/19] Implement backend abstraction and proxy backend Introduce a backend abstraction layer to decouple Vimini from provider-specific APIs. Add BaseClient and BaseChatSession interfaces along with unified StreamChunk and tool calling data structures. Implement GenAIBackend to wrap Google GenAI and ProxyBackend to support OpenAI-compatible endpoints over standard library HTTP/SSE streaming, including schema conversion and tool calling. This allows users to configure and switch between Gemini and OpenAI-compatible proxy backends. Assisted-by: Gemini:Gemini 3.8 Flash Signed-off-by: Simo Sorce --- PROXY-API.md | 51 +- python3/vimini/common/backends/__init__.py | 81 ++++ python3/vimini/common/backends/base.py | 108 +++++ .../vimini/common/backends/genai_backend.py | 197 ++++++++ .../vimini/common/backends/proxy_backend.py | 446 ++++++++++++++++++ tests/test_backend_abstraction.py | 247 ++++++++++ tests/test_proxy_backend.py | 309 ++++++++++++ 7 files changed, 1392 insertions(+), 47 deletions(-) create mode 100644 python3/vimini/common/backends/__init__.py create mode 100644 python3/vimini/common/backends/base.py create mode 100644 python3/vimini/common/backends/genai_backend.py create mode 100644 python3/vimini/common/backends/proxy_backend.py create mode 100644 tests/test_backend_abstraction.py create mode 100644 tests/test_proxy_backend.py diff --git a/PROXY-API.md b/PROXY-API.md index 312d62b..b17a711 100644 --- a/PROXY-API.md +++ b/PROXY-API.md @@ -23,56 +23,13 @@ Implemented and Documented elsewhere ## 3. Backend Abstraction Layer -A modular backend architecture isolates provider-specific communication behind a unified interface: - -``` -python3/vimini/common/ -├── backends/ -│ ├── __init__.py # Backend registry and factory: get_backend(name, config) -│ ├── base.py # Abstract interfaces: BaseClient, BaseChatSession, StreamChunk -│ ├── genai_backend.py # Google Gemini implementation (using google-genai) -│ └── proxy_backend.py # OpenAI-compatible implementation (using standard library HTTP/SSE) -``` - -### 3.1 Core Abstraction (`base.py`) - -- **`BaseClient`**: - - `list_models() -> List[Dict]`: Returns standardized model records (`name`, `display_name`, `description`, `supported_actions`). - - `generate_content(prompt, model, system_instruction=None, config=None) -> str`: Non-streaming generation for commit messages, autocomplete, etc. - - `generate_content_stream(prompt, model, system_instruction=None, config=None) -> Iterator[StreamChunk]`: Streaming generation for code generation. - - `create_chat(model, system_instruction, tools, history=None) -> BaseChatSession`: Interactive multi-turn chat sessions. - -- **`BaseChatSession`**: - - `send_message_stream(prompt_or_tool_responses) -> Iterator[StreamChunk]`: Sends prompts or tool results and yields chunks. - - `has_history() -> bool`: Returns whether the backend supports history retrieval. For `proxy_backend` (`openai`), this returns `False`. - - `get_history() -> List[Dict]`: Retrieves serializable chat history if supported (`has_history() == True`). Callers will check `has_history()` before attempting history operations. - -- **`StreamChunk`**: - - `text`: Incremental generation text. - - `thought`: Incremental reasoning/thinking content (supporting DeepSeek-R1 `` delimiters and OpenAI `reasoning_content` deltas). - - `tool_calls`: List of requested tool invocations (`name`, `args`, `call_id`). +Implemented and Documented elsewhere --- ## 4. ProxyAPI Implementation Details -### 4.1 Standard Library HTTP & SSE Client -- Build an SSE (Server-Sent Events) parser over `urllib.request` / `http.client`. -- Stream response deltas line by line (`data: { ... }`) to yield real-time text and thoughts to Vim. - -### 4.2 Tool & Function Calling Translation -- **Definitions**: Translate Vimini tool declarations (`apply_patch`, `read_file`, `list_directory`, `list_git_commits`, `get_git_commit`, `git_grep`, `build_code`, `test_code`, `fix_format`) into OpenAI `tools` schema format: - ```json - { - "type": "function", - "function": { - "name": "read_file", - "description": "...", - "parameters": { ... } - } - } - ``` -- **Responses**: Reconstruct tool calls from streaming `tool_calls` chunks, prompt user in Vim, and return results to the model using `{ "role": "tool", "tool_call_id": id, "content": result }`. +Implemented and Documented elsewhere --- @@ -126,8 +83,8 @@ Only the selected backend name is sent; the agent server reads `~/.var/vimini/co ## 7. Implementation Roadmap 1. **Phase 1: Config Management**: [COMPLETED] Implement configuration loader and automatic v1 default generator for `~/.var/vimini/config.json`. -2. **Phase 2: Abstraction & GenAI Adapter**: Refactor Google GenAI code into `GenAIBackend` matching `BaseClient` / `BaseChatSession` and adding `has_history()`. -3. **Phase 3: ProxyAPI Client**: Implement standard-library OpenAI client (`urllib.request`) supporting models listing, streaming completions, and tool calling. +2. **Phase 2: Abstraction & GenAI Adapter**: [COMPLETED] Refactor Google GenAI code into `GenAIBackend` matching `BaseClient` / `BaseChatSession` and adding `has_history()`. +3. **Phase 3: ProxyAPI Client**: [COMPLETED] Implement standard-library OpenAI client (`urllib.request`) supporting models listing, streaming completions, and tool calling. 4. **Phase 4: Agent Server & Session Integration**: Update `server.py`, `chat.py`, `code.py`, and `review.py` to use backend factory and honor `has_history()`. 5. **Phase 5: Vim Frontend & Commands**: Implement `:ViminiBackend` (with status echo and argument switching), single-group OpenAI model listing in `:ViminiListModels`, `:ViminiStatus`, and help documentation. 6. **Phase 6: Testing & Validation**: Add unit tests for config generation/parsing, backend switching, ProxyAPI mock requests, and single-group model listing. diff --git a/python3/vimini/common/backends/__init__.py b/python3/vimini/common/backends/__init__.py new file mode 100644 index 0000000..18110c8 --- /dev/null +++ b/python3/vimini/common/backends/__init__.py @@ -0,0 +1,81 @@ +from typing import Any, Dict, Optional + +from vimini.common.backends.base import ( + BaseChatSession, + BaseClient, + StreamChunk, + ToolCall, + ToolResponse, +) +from vimini.common.backends.genai_backend import GenAIBackend, GenAIChatSession +from vimini.common.backends.proxy_backend import ( + ProxyBackend, + ProxyChatSession, + convert_schema_to_openai, + convert_tools_to_openai, +) +from vimini.common.config import ( + load_global_config, + get_active_backend, + load_backend_api_key, +) + + +def get_backend( + backend_name: Optional[str] = None, + config: Optional[Dict[str, Any]] = None, + config_path: Optional[str] = None, +) -> BaseClient: + """ + Factory function returning a BaseClient instance for the requested or active backend. + """ + if config is None: + config = load_global_config(config_path=config_path) + + backends = config.get("backends", {}) + + if backend_name is None: + backend_name = get_active_backend(config) + + if backend_name not in backends: + available = list(backends.keys()) + raise ValueError( + f"Backend '{backend_name}' not found in configuration. Available: {available}" + ) + + backend_cfg = backends[backend_name] + api = backend_cfg.get("api", "genai") + + if api == "genai": + api_key = load_backend_api_key(backend_cfg) + return GenAIBackend( + api_key=api_key, + api_key_file=backend_cfg.get("api_key_file"), + config=backend_cfg, + ) + elif api == "openai": + api_key = load_backend_api_key(backend_cfg) + return ProxyBackend( + config=backend_cfg, + api_key=api_key, + base_url=backend_cfg.get("base_url"), + headers=backend_cfg.get("headers"), + ) + + raise ValueError(f"Unsupported API protocol '{api}' for backend '{backend_name}'.") + + +__all__ = [ + "BaseChatSession", + "BaseClient", + "StreamChunk", + "ToolCall", + "ToolResponse", + "GenAIBackend", + "GenAIChatSession", + "ProxyBackend", + "ProxyChatSession", + "convert_schema_to_openai", + "convert_tools_to_openai", + "get_backend", +] diff --git a/python3/vimini/common/backends/base.py b/python3/vimini/common/backends/base.py new file mode 100644 index 0000000..042ecae --- /dev/null +++ b/python3/vimini/common/backends/base.py @@ -0,0 +1,108 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Dict, Iterator, List, Optional + + +@dataclass +class ToolCall: + name: str + args: Dict[str, Any] = field(default_factory=dict) + call_id: Optional[str] = None + + +@dataclass +class ToolResponse: + name: str + response: Any + call_id: Optional[str] = None + + +@dataclass +class StreamChunk: + text: str = "" + thought: str = "" + tool_calls: List[ToolCall] = field(default_factory=list) + + +class BaseChatSession(ABC): + @abstractmethod + def send_message_stream( + self, prompt_or_tool_responses: Any + ) -> Iterator[StreamChunk]: + """ + Sends a prompt or tool responses to the model and yields StreamChunk instances. + """ + pass + + @abstractmethod + def has_history(self) -> bool: + """ + Returns whether the chat session supports history export. + """ + pass + + @abstractmethod + def get_history(self) -> List[Any]: + """ + Retrieves serializable chat history if supported. + """ + pass + + +class BaseClient(ABC): + @abstractmethod + def list_models(self) -> List[Dict[str, Any]]: + """ + Returns standardized model records: + [ + { + "name": str, + "display_name": str, + "description": str, + "supported_actions": List[str], + }, + ... + ] + """ + pass + + @abstractmethod + def generate_content( + self, + prompt: str, + model: str, + system_instruction: Optional[str] = None, + config: Optional[Any] = None, + ) -> str: + """ + Non-streaming content generation. + """ + pass + + @abstractmethod + def generate_content_stream( + self, + prompt: Any, + model: str, + system_instruction: Optional[str] = None, + config: Optional[Any] = None, + ) -> Iterator[StreamChunk]: + """ + Streaming content generation yielding StreamChunk instances. + """ + pass + + @abstractmethod + def create_chat( + self, + model: str, + system_instruction: Optional[str] = None, + tools: Optional[Any] = None, + history: Optional[Any] = None, + verbose: bool = False, + config: Optional[Any] = None, + ) -> BaseChatSession: + """ + Creates and returns a BaseChatSession for multi-turn chat. + """ + pass diff --git a/python3/vimini/common/backends/genai_backend.py b/python3/vimini/common/backends/genai_backend.py new file mode 100644 index 0000000..15f2d42 --- /dev/null +++ b/python3/vimini/common/backends/genai_backend.py @@ -0,0 +1,197 @@ +import logging +from typing import Any, Dict, Iterator, List, Optional +from google.genai import types +from vimini.common.backends.base import ( + BaseChatSession, + BaseClient, + StreamChunk, + ToolCall, + ToolResponse, +) +from vimini.common.genai import ( + get_client, + load_api_key, + create_generation_config, +) + +logger = logging.getLogger("vimini_agent") + + +class GenAIChatSession(BaseChatSession): + def __init__( + self, + genai_client, + model: str, + config: Optional[Any] = None, + history: Optional[Any] = None, + ): + self.client = genai_client + self.model = model + self.config = config + kwargs = {"model": model} + if config is not None: + kwargs["config"] = config + if history is not None: + kwargs["history"] = history + self.session = self.client.chats.create(**kwargs) + + def has_history(self) -> bool: + return True + + def get_history(self) -> List[Any]: + if self.session is not None and hasattr(self.session, "get_history"): + try: + return self.session.get_history() + except Exception as e: + logger.warning(f"Error getting GenAI chat history: {e}") + return [] + + def send_message_stream( + self, prompt_or_tool_responses: Any + ) -> Iterator[StreamChunk]: + call_arg = prompt_or_tool_responses + if isinstance(prompt_or_tool_responses, list): + converted = [] + for item in prompt_or_tool_responses: + if isinstance(item, ToolResponse): + resp_dict = ( + item.response + if isinstance(item.response, dict) + else {"result": item.response} + ) + converted.append( + types.Part.from_function_response( + name=item.name, response=resp_dict + ) + ) + else: + converted.append(item) + call_arg = converted + + response_stream = self.session.send_message_stream(call_arg) + for chunk in response_stream: + text = "" + thought = "" + tool_calls = [] + + if ( + getattr(chunk, "candidates", None) + and chunk.candidates + and getattr(chunk.candidates[0], "content", None) + and getattr(chunk.candidates[0].content, "parts", None) + ): + for part in chunk.candidates[0].content.parts: + if hasattr(part, "function_call") and part.function_call: + fc = part.function_call + args_dict = dict(fc.args) if getattr(fc, "args", None) else {} + tool_calls.append(ToolCall(name=fc.name, args=args_dict)) + elif getattr(part, "thought", False): + thought += getattr(part, "text", "") or "" + elif hasattr(part, "text") and part.text: + text += part.text + elif getattr(chunk, "text", None): + text += chunk.text + + yield StreamChunk(text=text, thought=thought, tool_calls=tool_calls) + + +class GenAIBackend(BaseClient): + def __init__( + self, + api_key: Optional[str] = None, + api_key_file: Optional[str] = None, + config: Optional[Dict[str, Any]] = None, + ): + if not api_key: + api_key = load_api_key(config=config, api_key_file=api_key_file) + self.client = get_client(api_key=api_key) + + def list_models(self) -> List[Dict[str, Any]]: + models_iter = self.client.models.list() + models = [] + for m in models_iter: + actions = ( + getattr(m, "supported_actions", None) + or getattr(m, "supported_generation_methods", None) + or [] + ) + if isinstance(actions, (list, tuple, set)): + actions = list(actions) + else: + actions = [] + models.append( + { + "name": m.name, + "display_name": getattr(m, "display_name", m.name), + "description": getattr(m, "description", "") or "", + "supported_actions": actions, + } + ) + return models + + def generate_content( + self, + prompt: str, + model: str, + system_instruction: Optional[str] = None, + config: Optional[Any] = None, + ) -> str: + if config is None: + config = create_generation_config( + disable_function_calling=True, + system_instruction=system_instruction, + ) + response = self.client.models.generate_content( + model=model, contents=prompt, config=config + ) + return getattr(response, "text", "") or "" + + def generate_content_stream( + self, + prompt: Any, + model: str, + system_instruction: Optional[str] = None, + config: Optional[Any] = None, + ) -> Iterator[StreamChunk]: + if config is None: + config = create_generation_config(system_instruction=system_instruction) + response_stream = self.client.models.generate_content_stream( + model=model, contents=prompt, config=config + ) + for chunk in response_stream: + text = "" + thought = "" + if ( + getattr(chunk, "candidates", None) + and chunk.candidates + and getattr(chunk.candidates[0], "content", None) + and getattr(chunk.candidates[0].content, "parts", None) + ): + for part in chunk.candidates[0].content.parts: + if getattr(part, "thought", False): + thought += getattr(part, "text", "") or "" + elif hasattr(part, "text") and part.text: + text += part.text + elif getattr(chunk, "text", None): + text += chunk.text + yield StreamChunk(text=text, thought=thought) + + def create_chat( + self, + model: str, + system_instruction: Optional[str] = None, + tools: Optional[Any] = None, + history: Optional[Any] = None, + verbose: bool = False, + config: Optional[Any] = None, + ) -> BaseChatSession: + if config is None: + config = create_generation_config( + tools=tools, + verbose=verbose, + system_instruction=system_instruction, + disable_function_calling=tools is None, + ) + return GenAIChatSession( + self.client, model=model, config=config, history=history + ) diff --git a/python3/vimini/common/backends/proxy_backend.py b/python3/vimini/common/backends/proxy_backend.py new file mode 100644 index 0000000..2ca2b8c --- /dev/null +++ b/python3/vimini/common/backends/proxy_backend.py @@ -0,0 +1,446 @@ +import json +import logging +import urllib.request +import urllib.error +from typing import Any, Dict, Iterator, List, Optional + +from vimini.common.backends.base import ( + BaseChatSession, + BaseClient, + StreamChunk, + ToolCall, + ToolResponse, +) +from vimini.common.config import load_backend_api_key + +logger = logging.getLogger("vimini_agent") + + +def convert_schema_to_openai(schema: Any) -> Dict[str, Any]: + """ + Converts a Google GenAI types.Schema or dict into OpenAI-compatible JSON schema. + """ + if schema is None: + return {"type": "object", "properties": {}} + + if isinstance(schema, dict): + res = dict(schema) + if "type" in res: + res["type"] = str(res["type"]).lower().split(".")[-1] + if "properties" in res and isinstance(res["properties"], dict): + res["properties"] = { + k: convert_schema_to_openai(v) for k, v in res["properties"].items() + } + return res + + type_val = getattr(schema, "type", "object") + type_str = str(type_val).lower().split(".")[-1] + if type_str == "type": + type_str = "object" + + res: Dict[str, Any] = {"type": type_str} + + desc = getattr(schema, "description", None) + if desc: + res["description"] = desc + + properties = getattr(schema, "properties", None) + if properties and isinstance(properties, dict): + res["properties"] = { + k: convert_schema_to_openai(v) for k, v in properties.items() + } + + required = getattr(schema, "required", None) + if required and isinstance(required, list): + res["required"] = required + + enum = getattr(schema, "enum", None) + if enum and isinstance(enum, list): + res["enum"] = enum + + return res + + +def convert_tools_to_openai(tools: Any) -> List[Dict[str, Any]]: + """ + Converts a list of GenAI Tool or FunctionDeclaration objects to OpenAI tools format. + """ + if not tools: + return [] + + openai_tools: List[Dict[str, Any]] = [] + + tool_items = tools if isinstance(tools, (list, tuple)) else [tools] + + for item in tool_items: + if isinstance(item, dict): + if item.get("type") == "function" and "function" in item: + openai_tools.append(item) + elif "name" in item: + openai_tools.append( + { + "type": "function", + "function": { + "name": item["name"], + "description": item.get("description", ""), + "parameters": convert_schema_to_openai( + item.get("parameters") + ), + }, + } + ) + continue + + # Handle google.genai.types.Tool + if hasattr(item, "function_declarations") and item.function_declarations: + for fd in item.function_declarations: + openai_tools.append( + { + "type": "function", + "function": { + "name": fd.name, + "description": getattr(fd, "description", "") or "", + "parameters": convert_schema_to_openai( + getattr(fd, "parameters", None) + ), + }, + } + ) + continue + + # Handle google.genai.types.FunctionDeclaration + if hasattr(item, "name"): + openai_tools.append( + { + "type": "function", + "function": { + "name": item.name, + "description": getattr(item, "description", "") or "", + "parameters": convert_schema_to_openai( + getattr(item, "parameters", None) + ), + }, + } + ) + + return openai_tools + + +class ProxyChatSession(BaseChatSession): + def __init__( + self, + backend: "ProxyBackend", + model: str, + system_instruction: Optional[str] = None, + tools: Optional[List[Dict[str, Any]]] = None, + ): + self.backend = backend + self.model = model + self.tools = tools or [] + self.messages: List[Dict[str, Any]] = [] + if system_instruction: + self.messages.append({"role": "system", "content": system_instruction}) + + def has_history(self) -> bool: + return False + + def get_history(self) -> List[Any]: + return [] + + def send_message_stream( + self, prompt_or_tool_responses: Any + ) -> Iterator[StreamChunk]: + if isinstance(prompt_or_tool_responses, str): + self.messages.append({"role": "user", "content": prompt_or_tool_responses}) + elif isinstance(prompt_or_tool_responses, list): + 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) + ) + call_id = item.call_id or f"call_{i}" + self.messages.append( + { + "role": "tool", + "tool_call_id": call_id, + "name": item.name, + "content": content, + } + ) + elif isinstance(item, dict): + self.messages.append(item) + + payload: Dict[str, Any] = { + "model": self.model, + "messages": list(self.messages), + "stream": True, + } + if self.tools: + payload["tools"] = self.tools + + resp = self.backend._make_request( + "/chat/completions", data=payload, stream=True + ) + + accumulated_text = "" + accumulated_thought = "" + tool_call_chunks: Dict[int, Dict[str, Any]] = {} + + for event_data in self.backend._read_sse_stream(resp): + choices = event_data.get("choices", []) + if not choices: + continue + + delta = choices[0].get("delta", {}) + text_chunk = delta.get("content") or "" + thought_chunk = ( + delta.get("reasoning_content") or delta.get("reasoning") or "" + ) + + if text_chunk: + accumulated_text += text_chunk + if thought_chunk: + accumulated_thought += thought_chunk + + raw_tool_calls = delta.get("tool_calls") + if raw_tool_calls: + for tc in raw_tool_calls: + idx = tc.get("index", 0) + if idx not in tool_call_chunks: + tool_call_chunks[idx] = { + "id": tc.get("id"), + "name": tc.get("function", {}).get("name", ""), + "arguments": tc.get("function", {}).get("arguments", ""), + } + else: + if tc.get("id"): + tool_call_chunks[idx]["id"] = tc.get("id") + if tc.get("function", {}).get("name"): + tool_call_chunks[idx]["name"] += tc.get("function", {}).get( + "name" + ) + if tc.get("function", {}).get("arguments"): + tool_call_chunks[idx]["arguments"] += tc.get( + "function", {} + ).get("arguments") + + if text_chunk or thought_chunk: + yield StreamChunk(text=text_chunk, thought=thought_chunk) + + # Assemble completed tool calls + completed_tool_calls: List[ToolCall] = [] + if tool_call_chunks: + for idx in sorted(tool_call_chunks.keys()): + tc_info = tool_call_chunks[idx] + call_id = tc_info.get("id") or f"call_{idx}" + args_str = tc_info.get("arguments", "") + try: + args_dict = json.loads(args_str) if args_str else {} + except Exception: + args_dict = {"raw": args_str} + + completed_tool_calls.append( + ToolCall(name=tc_info["name"], args=args_dict, call_id=call_id) + ) + + yield StreamChunk(tool_calls=completed_tool_calls) + + # Record assistant response in local conversation history + asst_msg: Dict[str, Any] = {"role": "assistant"} + if accumulated_text: + asst_msg["content"] = accumulated_text + if completed_tool_calls: + asst_msg["tool_calls"] = [ + { + "id": tc.call_id or f"call_{i}", + "type": "function", + "function": { + "name": tc.name, + "arguments": json.dumps(tc.args), + }, + } + for i, tc in enumerate(completed_tool_calls) + ] + if accumulated_text or completed_tool_calls: + self.messages.append(asst_msg) + + +class ProxyBackend(BaseClient): + def __init__( + self, + config: Optional[Dict[str, Any]] = None, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + ): + self.config = config or {} + if api_key is None and config: + 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.headers = dict(headers or self.config.get("headers", {})) + + def _make_request( + self, endpoint: str, data: Optional[Dict[str, Any]] = None, stream: bool = False + ): + url = f"{self.base_url}{endpoint}" + req_headers = { + "Content-Type": "application/json", + "User-Agent": "Vimini/ProxyAPI", + } + if self.api_key: + req_headers["Authorization"] = f"Bearer {self.api_key}" + req_headers.update(self.headers) + + body_bytes = json.dumps(data).encode("utf-8") if data is not None else None + req = urllib.request.Request( + url, + data=body_bytes, + headers=req_headers, + method="POST" if data is not None else "GET", + ) + + try: + resp = urllib.request.urlopen(req, timeout=120 if stream else 60) + if stream: + return resp + resp_bytes = resp.read() + return json.loads(resp_bytes.decode("utf-8")) + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8", errors="replace") + logger.error(f"ProxyAPI HTTP error {e.code} for {url}: {err_body}") + raise RuntimeError(f"ProxyAPI HTTP error {e.code}: {err_body}") from e + except Exception as e: + logger.error(f"ProxyAPI request error for {url}: {e}") + raise RuntimeError(f"ProxyAPI connection error: {e}") from e + + def _read_sse_stream(self, resp) -> Iterator[Dict[str, Any]]: + buffer = "" + try: + for raw_line in resp: + line = raw_line.decode("utf-8", errors="replace") + buffer += line + while "\n" in buffer: + line_str, buffer = buffer.split("\n", 1) + line_str = line_str.strip() + if not line_str or line_str.startswith(":"): + continue + if line_str.startswith("data:"): + data_str = line_str[5:].strip() + if data_str == "[DONE]": + return + try: + yield json.loads(data_str) + except json.JSONDecodeError: + continue + finally: + try: + resp.close() + except Exception: + 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("name") or m_id + desc = m.get("description", "") + else: + continue + + if not m_id: + continue + + models.append( + { + "name": m_id, + "display_name": disp_name, + "description": desc, + "supported_actions": ["generateContent"], + } + ) + return models + + def generate_content( + self, + prompt: str, + model: str, + system_instruction: Optional[str] = None, + config: Optional[Any] = None, + ) -> str: + messages: List[Dict[str, Any]] = [] + if system_instruction: + messages.append({"role": "system", "content": system_instruction}) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": model, + "messages": messages, + "stream": False, + } + data = self._make_request("/chat/completions", data=payload, stream=False) + choices = data.get("choices", []) + if choices: + msg = choices[0].get("message", {}) + return msg.get("content", "") or "" + return "" + + def generate_content_stream( + self, + prompt: Any, + model: str, + system_instruction: Optional[str] = None, + config: Optional[Any] = None, + ) -> Iterator[StreamChunk]: + messages: List[Dict[str, Any]] = [] + if system_instruction: + messages.append({"role": "system", "content": system_instruction}) + + prompt_str = prompt if isinstance(prompt, str) else str(prompt) + messages.append({"role": "user", "content": prompt_str}) + + payload = { + "model": model, + "messages": messages, + "stream": True, + } + resp = self._make_request("/chat/completions", data=payload, stream=True) + for event_data in self._read_sse_stream(resp): + choices = event_data.get("choices", []) + if not choices: + continue + delta = choices[0].get("delta", {}) + text_chunk = delta.get("content") or "" + thought_chunk = ( + delta.get("reasoning_content") or delta.get("reasoning") or "" + ) + if text_chunk or thought_chunk: + yield StreamChunk(text=text_chunk, thought=thought_chunk) + + def create_chat( + self, + model: str, + system_instruction: Optional[str] = None, + tools: Optional[Any] = None, + history: Optional[Any] = None, + verbose: bool = False, + config: Optional[Any] = None, + ) -> BaseChatSession: + openai_tools = convert_tools_to_openai(tools) + return ProxyChatSession( + backend=self, + model=model, + system_instruction=system_instruction, + tools=openai_tools, + ) diff --git a/tests/test_backend_abstraction.py b/tests/test_backend_abstraction.py new file mode 100644 index 0000000..39042d3 --- /dev/null +++ b/tests/test_backend_abstraction.py @@ -0,0 +1,247 @@ +import sys +import os +import pytest +from unittest.mock import MagicMock, patch + +sys.path.insert( + 0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "python3")) +) + +from vimini.common.backends.base import ( + BaseChatSession, + BaseClient, + StreamChunk, + ToolCall, + ToolResponse, +) +from vimini.common.backends.genai_backend import GenAIBackend, GenAIChatSession +from vimini.common.backends.proxy_backend import ProxyBackend +from vimini.common.backends import get_backend + + +def test_stream_chunk_and_tool_classes(): + tc = ToolCall(name="read_file", args={"filepath": "test.py"}, call_id="123") + assert tc.name == "read_file" + assert tc.args == {"filepath": "test.py"} + assert tc.call_id == "123" + + tr = ToolResponse(name="read_file", response="content", call_id="123") + assert tr.name == "read_file" + assert tr.response == "content" + assert tr.call_id == "123" + + chunk = StreamChunk(text="hello", thought="thinking", tool_calls=[tc]) + assert chunk.text == "hello" + assert chunk.thought == "thinking" + assert len(chunk.tool_calls) == 1 + + +def test_get_backend_genai(tmp_path): + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": { + "api": "genai", + "api_key_file": "~/.config/gemini.token", + "default_model": "gemini-3.8-flash", + } + }, + } + + with patch("vimini.common.backends.genai_backend.get_client") as mock_get_client: + backend = get_backend("Gemini", config=config) + assert isinstance(backend, GenAIBackend) + mock_get_client.assert_called_once() + + +def test_get_backend_defaults_to_active(tmp_path): + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": { + "api": "genai", + "default_model": "gemini-3.8-flash", + } + }, + } + + with patch("vimini.common.backends.genai_backend.get_client"): + backend = get_backend(config=config) + assert isinstance(backend, GenAIBackend) + + +def test_get_backend_unknown(): + config = {"active_backend": "Gemini", "backends": {"Gemini": {"api": "genai"}}} + with pytest.raises(ValueError, match="Backend 'NonExistent' not found"): + get_backend("NonExistent", config=config) + + +def test_get_backend_unsupported_api(): + config = { + "active_backend": "Custom", + "backends": {"Custom": {"api": "unknown_api"}}, + } + with pytest.raises(ValueError, match="Unsupported API protocol 'unknown_api'"): + get_backend("Custom", config=config) + + +def test_get_backend_openai(): + config = { + "active_backend": "Custom 1", + "backends": {"Custom 1": {"api": "openai"}}, + } + backend = get_backend("Custom 1", config=config) + assert isinstance(backend, ProxyBackend) + + +def test_genai_backend_list_models(): + mock_client = MagicMock() + mock_model = MagicMock() + mock_model.name = "models/gemini-pro" + mock_model.display_name = "Gemini Pro" + mock_model.description = "Pro model" + mock_model.supported_actions = ["generateContent"] + + mock_client.models.list.return_value = [mock_model] + + with patch( + "vimini.common.backends.genai_backend.get_client", return_value=mock_client + ): + backend = GenAIBackend(api_key="fake-key") + models = backend.list_models() + + assert len(models) == 1 + assert models[0]["name"] == "models/gemini-pro" + assert models[0]["display_name"] == "Gemini Pro" + assert models[0]["description"] == "Pro model" + assert models[0]["supported_actions"] == ["generateContent"] + + +def test_genai_backend_generate_content(): + mock_client = MagicMock() + mock_resp = MagicMock() + mock_resp.text = "Generated text" + mock_client.models.generate_content.return_value = mock_resp + + with patch( + "vimini.common.backends.genai_backend.get_client", return_value=mock_client + ): + backend = GenAIBackend(api_key="fake-key") + res = backend.generate_content("Hello", model="gemini-3.8-flash") + + assert res == "Generated text" + mock_client.models.generate_content.assert_called_once() + + +def test_genai_backend_generate_content_stream(): + mock_client = MagicMock() + mock_chunk1 = MagicMock() + mock_chunk1.candidates = [] + mock_chunk1.text = "Hello " + + mock_part_thought = MagicMock() + mock_part_thought.thought = True + mock_part_thought.text = "Thinking..." + + mock_part_text = MagicMock() + mock_part_text.thought = False + mock_part_text.function_call = None + mock_part_text.text = "World" + + mock_chunk2 = MagicMock() + mock_candidate = MagicMock() + mock_candidate.content.parts = [mock_part_thought, mock_part_text] + mock_chunk2.candidates = [mock_candidate] + mock_chunk2.text = None + + mock_client.models.generate_content_stream.return_value = [ + mock_chunk1, + mock_chunk2, + ] + + with patch( + "vimini.common.backends.genai_backend.get_client", return_value=mock_client + ): + backend = GenAIBackend(api_key="fake-key") + chunks = list( + backend.generate_content_stream("Prompt", model="gemini-3.8-flash") + ) + + assert len(chunks) == 2 + assert chunks[0].text == "Hello " + assert chunks[1].thought == "Thinking..." + assert chunks[1].text == "World" + + +def test_genai_chat_session(): + mock_client = MagicMock() + mock_chat_obj = MagicMock() + mock_client.chats.create.return_value = mock_chat_obj + + mock_chat_obj.get_history.return_value = [{"role": "user", "text": "hi"}] + + with patch( + "vimini.common.backends.genai_backend.get_client", return_value=mock_client + ): + backend = GenAIBackend(api_key="fake-key") + session = backend.create_chat(model="gemini-3.8-flash") + + assert isinstance(session, GenAIChatSession) + assert session.has_history() is True + assert session.get_history() == [{"role": "user", "text": "hi"}] + + +def test_genai_chat_session_send_message_stream(): + mock_client = MagicMock() + mock_chat_obj = MagicMock() + mock_client.chats.create.return_value = mock_chat_obj + + # Create mock response chunks + mock_chunk = MagicMock() + mock_fc = MagicMock() + mock_fc.name = "read_file" + mock_fc.args = {"filepath": "foo.py"} + + mock_part_fc = MagicMock() + mock_part_fc.thought = False + mock_part_fc.function_call = mock_fc + + mock_cand = MagicMock() + mock_cand.content.parts = [mock_part_fc] + mock_chunk.candidates = [mock_cand] + + mock_chat_obj.send_message_stream.return_value = [mock_chunk] + + session = GenAIChatSession(mock_client, model="gemini-3.8-flash") + chunks = list(session.send_message_stream("List files")) + + assert len(chunks) == 1 + assert len(chunks[0].tool_calls) == 1 + assert chunks[0].tool_calls[0].name == "read_file" + assert chunks[0].tool_calls[0].args == {"filepath": "foo.py"} + + +def test_genai_chat_session_send_tool_response(): + mock_client = MagicMock() + mock_chat_obj = MagicMock() + mock_client.chats.create.return_value = mock_chat_obj + + mock_chunk = MagicMock() + mock_chunk.candidates = [] + mock_chunk.text = "File contents received." + mock_chat_obj.send_message_stream.return_value = [mock_chunk] + + session = GenAIChatSession(mock_client, model="gemini-3.8-flash") + tool_resp = ToolResponse(name="read_file", response="file data") + + with patch( + "google.genai.types.Part.from_function_response" + ) as mock_from_fn_response: + mock_from_fn_response.return_value = MagicMock() + chunks = list(session.send_message_stream([tool_resp])) + + assert len(chunks) == 1 + assert chunks[0].text == "File contents received." + mock_from_fn_response.assert_called_once_with( + name="read_file", response={"result": "file data"} + ) diff --git a/tests/test_proxy_backend.py b/tests/test_proxy_backend.py new file mode 100644 index 0000000..c687923 --- /dev/null +++ b/tests/test_proxy_backend.py @@ -0,0 +1,309 @@ +import io +import json +import os +import sys +import pytest +from unittest.mock import MagicMock, patch +from urllib.error import HTTPError + +sys.path.insert( + 0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "python3")) +) + +from google.genai import types +from vimini.common.backends.base import StreamChunk, ToolCall, ToolResponse +from vimini.common.backends.proxy_backend import ( + ProxyBackend, + ProxyChatSession, + convert_schema_to_openai, + convert_tools_to_openai, +) +from vimini.common.backends import get_backend + + +def test_convert_schema_to_openai(): + schema = types.Schema( + type=types.Type.OBJECT, + description="A test object", + properties={ + "path": types.Schema(type=types.Type.STRING, description="File path"), + "count": types.Schema(type=types.Type.INTEGER, enum=["1", "2"]), + }, + required=["path"], + ) + + converted = convert_schema_to_openai(schema) + assert converted["type"] == "object" + assert converted["description"] == "A test object" + assert "path" in converted["properties"] + assert converted["properties"]["path"]["type"] == "string" + assert converted["properties"]["path"]["description"] == "File path" + assert converted["properties"]["count"]["type"] == "integer" + assert converted["properties"]["count"]["enum"] == ["1", "2"] + assert converted["required"] == ["path"] + + +def test_convert_schema_none_and_dict(): + assert convert_schema_to_openai(None) == {"type": "object", "properties": {}} + + dict_schema = {"type": "STRING", "description": "test"} + conv = convert_schema_to_openai(dict_schema) + assert conv["type"] == "string" + assert conv["description"] == "test" + + +def test_convert_tools_to_openai(): + genai_tool = types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name="read_file", + description="Reads file", + parameters=types.Schema( + type=types.Type.OBJECT, + properties={"filepath": types.Schema(type=types.Type.STRING)}, + required=["filepath"], + ), + ) + ] + ) + + openai_tools = convert_tools_to_openai([genai_tool]) + assert len(openai_tools) == 1 + assert openai_tools[0]["type"] == "function" + fn = openai_tools[0]["function"] + assert fn["name"] == "read_file" + assert fn["description"] == "Reads file" + assert fn["parameters"]["type"] == "object" + assert "filepath" in fn["parameters"]["properties"] + + +def test_convert_tools_dict(): + dict_tool = { + "type": "function", + "function": {"name": "test_cmd", "parameters": {}}, + } + tools = convert_tools_to_openai([dict_tool]) + assert len(tools) == 1 + assert tools[0]["function"]["name"] == "test_cmd" + + +def test_proxy_backend_init(): + backend = ProxyBackend( + config={ + "base_url": "https://custom.api/v1/", + "headers": {"X-Test": "123"}, + }, + api_key="my-key", + ) + assert backend.base_url == "https://custom.api/v1" + assert backend.api_key == "my-key" + assert backend.headers == {"X-Test": "123"} + + +def test_proxy_backend_make_request_headers(): + backend = ProxyBackend( + base_url="https://api.openai.com/v1", + api_key="secret", + headers={"X-Custom": "val"}, + ) + + mock_resp = MagicMock() + mock_resp.read.return_value = b'{"status": "ok"}' + + with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: + res = backend._make_request("/test", data={"hello": "world"}) + assert res == {"status": "ok"} + + req = mock_urlopen.call_args[0][0] + assert req.full_url == "https://api.openai.com/v1/test" + assert req.headers["Authorization"] == "Bearer secret" + assert req.headers["X-custom"] == "val" + assert req.headers["Content-type"] == "application/json" + + +def test_proxy_backend_make_request_error(): + backend = ProxyBackend() + with patch( + "urllib.request.urlopen", + side_effect=HTTPError( + "url", 401, "Unauthorized", {}, io.BytesIO(b"Invalid API Key") + ), + ): + with pytest.raises( + RuntimeError, match="ProxyAPI HTTP error 401: Invalid API Key" + ): + backend._make_request("/test") + + +def test_proxy_backend_list_models(): + backend = ProxyBackend() + mock_data = { + "object": "list", + "data": [ + {"id": "gpt-4o", "name": "GPT-4o", "description": "Smart model"}, + {"id": "o3-mini"}, + ], + } + + with patch.object(backend, "_make_request", return_value=mock_data): + models = backend.list_models() + assert len(models) == 2 + assert models[0]["name"] == "gpt-4o" + assert models[0]["display_name"] == "GPT-4o" + assert models[0]["description"] == "Smart model" + assert models[0]["supported_actions"] == ["generateContent"] + + assert models[1]["name"] == "o3-mini" + assert models[1]["display_name"] == "o3-mini" + + +def test_proxy_backend_generate_content(): + backend = ProxyBackend() + mock_resp_data = { + "choices": [ + { + "message": { + "role": "assistant", + "content": "This is generated content.", + } + } + ] + } + + with patch.object(backend, "_make_request", return_value=mock_resp_data): + text = backend.generate_content( + "Hello", model="gpt-4o", system_instruction="Be brief" + ) + assert text == "This is generated content." + + +def test_proxy_backend_generate_content_stream(): + backend = ProxyBackend() + + sse_lines = [ + b'data: {"choices": [{"delta": {"reasoning_content": "Thinking..."}}]}\n\n', + b'data: {"choices": [{"delta": {"content": "Hello"}}]}\n\n', + b'data: {"choices": [{"delta": {"content": " World"}}]}\n\n', + b"data: [DONE]\n\n", + ] + mock_resp = io.BytesIO(b"".join(sse_lines)) + + with patch.object(backend, "_make_request", return_value=mock_resp): + chunks = list(backend.generate_content_stream("Hi", model="gpt-4o")) + + assert len(chunks) == 3 + assert chunks[0].thought == "Thinking..." + assert chunks[1].text == "Hello" + assert chunks[2].text == " World" + + +def test_proxy_chat_session_streaming_and_tools(): + backend = ProxyBackend() + session = backend.create_chat( + model="gpt-4o", + system_instruction="System prompt", + tools=[{"name": "read_file", "parameters": {}}], + ) + + assert isinstance(session, ProxyChatSession) + assert session.has_history() is False + assert session.get_history() == [] + + chunk_text = {"choices": [{"delta": {"content": "Let me read the file."}}]} + chunk_tc1 = { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "function": { + "name": "read_file", + "arguments": '{"file', + }, + } + ] + } + } + ] + } + chunk_tc2 = { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "function": {"arguments": 'path": "test.py"}'}, + } + ] + } + } + ] + } + + sse_lines = [ + f"data: {json.dumps(chunk_text)}\n\n".encode("utf-8"), + f"data: {json.dumps(chunk_tc1)}\n\n".encode("utf-8"), + f"data: {json.dumps(chunk_tc2)}\n\n".encode("utf-8"), + b"data: [DONE]\n\n", + ] + mock_resp = io.BytesIO(b"".join(sse_lines)) + + with patch.object(backend, "_make_request", return_value=mock_resp): + chunks = list(session.send_message_stream("Check file")) + + # Should yield text chunk first, then tool_calls chunk at stream end + assert len(chunks) == 2 + assert chunks[0].text == "Let me read the file." + assert len(chunks[1].tool_calls) == 1 + tc = chunks[1].tool_calls[0] + assert tc.name == "read_file" + assert tc.args == {"filepath": "test.py"} + assert tc.call_id == "call_1" + + # Now verify tool response turn + resp_sse_lines = [ + b'data: {"choices": [{"delta": {"content": "File content processed."}}]}\n\n', + b"data: [DONE]\n\n", + ] + mock_resp2 = io.BytesIO(b"".join(resp_sse_lines)) + + tool_res = ToolResponse( + name="read_file", response="file content here", call_id="call_1" + ) + with patch.object(backend, "_make_request", return_value=mock_resp2) as mock_req: + chunks2 = list(session.send_message_stream([tool_res])) + assert len(chunks2) == 1 + assert chunks2[0].text == "File content processed." + + # Verify messages sent to API in second turn + call_payload = mock_req.call_args[1]["data"] + messages = call_payload["messages"] + # Messages should contain: system, user, assistant (with tool_calls), tool response + assert len(messages) == 4 + assert messages[0]["role"] == "system" + assert messages[1]["role"] == "user" + assert messages[2]["role"] == "assistant" + assert messages[2]["tool_calls"][0]["function"]["name"] == "read_file" + assert messages[3]["role"] == "tool" + assert messages[3]["tool_call_id"] == "call_1" + assert messages[3]["content"] == "file content here" + + +def test_get_backend_factory_resolves_proxy_backend(): + config = { + "active_backend": "Custom 1", + "backends": { + "Custom 1": { + "api": "openai", + "base_url": "https://api.proxy.com/v1", + "default_model": "gpt-4o", + } + }, + } + + backend = get_backend("Custom 1", config=config) + assert isinstance(backend, ProxyBackend) + assert backend.base_url == "https://api.proxy.com/v1" From e057f251f667843085b433468baffb509c55ba6f Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 21:14:49 -0400 Subject: [PATCH 03/19] Connect agent server and sessions to backends Refactor the agent server and chat, code, and review sessions to interact with language models via generic backend client interfaces rather than direct Google GenAI dependencies. Agent tools and schema declarations now use shared backend types, streaming handlers consume unified stream chunks, and chat history queries respect backends that do not persist history. Also update IPC setup to transmit the active backend name. Assisted-by: Gemini:Gemini 3.8 Flash Signed-off-by: Simo Sorce --- PROXY-API.md | 23 +- python3/vimini/agent/chat.py | 375 ++++++++++-------- python3/vimini/agent/code.py | 74 ++-- python3/vimini/agent/review.py | 162 +++++--- python3/vimini/agent/server.py | 37 +- python3/vimini/common/backends/__init__.py | 70 +++- python3/vimini/common/backends/base.py | 66 +++ .../vimini/common/backends/genai_backend.py | 163 +++++++- .../vimini/common/backends/proxy_backend.py | 6 + python3/vimini/main.py | 16 +- python3/vimini/util.py | 2 - tests/test_agent_backend_integration.py | 128 ++++++ 12 files changed, 803 insertions(+), 319 deletions(-) create mode 100644 tests/test_agent_backend_integration.py diff --git a/PROXY-API.md b/PROXY-API.md index b17a711..3d0b51a 100644 --- a/PROXY-API.md +++ b/PROXY-API.md @@ -57,26 +57,7 @@ Implemented and Documented elsewhere ## 6. Agent Server & IPC Protocol Updates -### 6.1 Setup Message Payload -Update `send_setup()` in `main.py` to transmit the active backend name: - -```json -{ - "jsonrpc": "2.0", - "id": "setup", - "method": "setup", - "params": { - "backend": "Custom 1" - } -} -``` - -Only the selected backend name is sent; the agent server reads `~/.var/vimini/config.json` to load the corresponding backend configuration options. - -### 6.2 Agent Server Handlers -- Update `execute_function` in `server.py` to route `list_models`, `commit`, and `autocomplete` through the active backend client. -- Update `ChatSession` in `agent/chat.py`, `CodeSession` in `agent/code.py`, and `ReviewSession` in `agent/review.py` to obtain their client and session via `get_backend(agent_config)`. -- Update chat session history callers to check `has_history()` and bypass history handling if false. +Implemented and Documented elsewhere --- @@ -85,7 +66,7 @@ Only the selected backend name is sent; the agent server reads `~/.var/vimini/co 1. **Phase 1: Config Management**: [COMPLETED] Implement configuration loader and automatic v1 default generator for `~/.var/vimini/config.json`. 2. **Phase 2: Abstraction & GenAI Adapter**: [COMPLETED] Refactor Google GenAI code into `GenAIBackend` matching `BaseClient` / `BaseChatSession` and adding `has_history()`. 3. **Phase 3: ProxyAPI Client**: [COMPLETED] Implement standard-library OpenAI client (`urllib.request`) supporting models listing, streaming completions, and tool calling. -4. **Phase 4: Agent Server & Session Integration**: Update `server.py`, `chat.py`, `code.py`, and `review.py` to use backend factory and honor `has_history()`. +4. **Phase 4: Agent Server & Session Integration**: [COMPLETED] Update `server.py`, `chat.py`, `code.py`, and `review.py` to use backend factory and honor `has_history()`. 5. **Phase 5: Vim Frontend & Commands**: Implement `:ViminiBackend` (with status echo and argument switching), single-group OpenAI model listing in `:ViminiListModels`, `:ViminiStatus`, and help documentation. 6. **Phase 6: Testing & Validation**: Add unit tests for config generation/parsing, backend switching, ProxyAPI mock requests, and single-group model listing. diff --git a/python3/vimini/agent/chat.py b/python3/vimini/agent/chat.py index 1f92a4a..339b153 100644 --- a/python3/vimini/agent/chat.py +++ b/python3/vimini/agent/chat.py @@ -5,8 +5,6 @@ import logging import tempfile import sys -from google import genai -from google.genai import types from vimini.common.util import ( get_project_root, get_project_name, @@ -23,11 +21,24 @@ generate_diff_for_file, ) from vimini.agent.comms import CommSession -from vimini.common.genai import get_client, load_api_key, create_generation_config +from vimini.common.backends import ( + get_client, + Tool, + FunctionDeclaration, + Schema, + Type, + ToolResponse, + StreamChunk, +) logger = logging.getLogger("vimini_agent") +def create_generation_config(**kwargs): + """Generic configuration dictionary helper for backwards compatibility.""" + return kwargs + + def generate_tool_declaration_schema(tool_label, tool_config): """ Generates (description, parameters_schema) for build_code, test_code, or fix_format @@ -43,7 +54,7 @@ def generate_tool_declaration_schema(tool_label, tool_config): if not tool_config: return ( f"{tool_action}. (No {tool_label} command is currently configured in project options).", - types.Schema(type=types.Type.OBJECT), + Schema(type=Type.OBJECT), ) if tool_config["type"] == "simple": @@ -53,7 +64,7 @@ def generate_tool_declaration_schema(tool_label, tool_config): f"Configured command: '{cmd}'.\n" f"This command accepts no options or arguments. Call this tool with an empty object {{}}." ) - return (desc, types.Schema(type=types.Type.OBJECT, properties={})) + return (desc, Schema(type=Type.OBJECT, properties={})) if tool_config["type"] == "alternatives": alts = tool_config.get("alternatives", []) @@ -70,11 +81,11 @@ def generate_tool_declaration_schema(tool_label, tool_config): f"These alternative commands are all-or-nothing and do not accept any additional flags or arguments.\n" f"Available commands:\n" + "\n".join(alt_lines) ) - params = types.Schema( - type=types.Type.OBJECT, + params = Schema( + type=Type.OBJECT, properties={ - "command": types.Schema( - type=types.Type.STRING, + "command": Schema( + type=Type.STRING, enum=cmd_enum, description="The exact alternative command to run. Must be one of the allowed choices.", ) @@ -101,23 +112,21 @@ def generate_tool_declaration_schema(tool_label, tool_config): has_value = opt["has_value"] if not has_value: full_desc = f"Boolean flag '{flag}' (no value). Set to true to include this flag. {opt_desc}".strip() - properties[name] = types.Schema( - type=types.Type.BOOLEAN, description=full_desc - ) + properties[name] = Schema(type=Type.BOOLEAN, description=full_desc) opt_lines.append(f"- {name} (flag '{flag}', boolean): {opt_desc}") else: if choices: full_desc = f"Flag '{flag} '. Allowed values: {choices}. {opt_desc}".strip() - properties[name] = types.Schema( - type=types.Type.STRING, enum=choices, description=full_desc + properties[name] = Schema( + type=Type.STRING, enum=choices, description=full_desc ) opt_lines.append( f"- {name} (flag '{flag}', choices: {choices}): {opt_desc}" ) else: full_desc = f"Flag '{flag} '. Free-form string argument. {opt_desc}".strip() - properties[name] = types.Schema( - type=types.Type.STRING, description=full_desc + properties[name] = Schema( + type=Type.STRING, description=full_desc ) opt_lines.append( f"- {name} (flag '{flag}', string value): {opt_desc}" @@ -126,8 +135,8 @@ def generate_tool_declaration_schema(tool_label, tool_config): # Argument if choices: full_desc = f"Positional argument (single string). Allowed values: {choices}. {opt_desc}".strip() - properties[name] = types.Schema( - type=types.Type.STRING, enum=choices, description=full_desc + properties[name] = Schema( + type=Type.STRING, enum=choices, description=full_desc ) opt_lines.append( f"- {name} (argument, choices: {choices}): {opt_desc}" @@ -136,9 +145,7 @@ def generate_tool_declaration_schema(tool_label, tool_config): full_desc = ( f"Positional argument (single string). {opt_desc}".strip() ) - properties[name] = types.Schema( - type=types.Type.STRING, description=full_desc - ) + properties[name] = Schema(type=Type.STRING, description=full_desc) opt_lines.append(f"- {name} (argument, string): {opt_desc}") if opt.get("required"): @@ -152,14 +159,14 @@ def generate_tool_declaration_schema(tool_label, tool_config): f"Usage rules: You may pass any of the available options or arguments defined above within their valid schema. " f"Boolean flags should be true or false. Options with values and positional arguments must each be a single string argument." ) - params = types.Schema( - type=types.Type.OBJECT, + params = Schema( + type=Type.OBJECT, properties=properties, required=required if required else None, ) return desc, params - return (f"{tool_action}.", types.Schema(type=types.Type.OBJECT)) + return (f"{tool_action}.", Schema(type=Type.OBJECT)) def get_agent_tools(project_root=None): @@ -174,140 +181,140 @@ def get_agent_tools(project_root=None): ) return [ - types.Tool( + Tool( function_declarations=[ - types.FunctionDeclaration( + FunctionDeclaration( name="apply_patch", description="Applies file modifications or creates new files. " "You can provide the entire file contents using file_path and file_content " "(strongly preferred, as a clean unified diff is generated locally to show the user), " "or provide a unified diff patch via diff_content. " "Ensure file paths are relative to the project root directory.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "file_path": types.Schema( - type=types.Type.STRING, + "file_path": Schema( + type=Type.STRING, description="The path to the file to modify or create, relative to the project root.", ), - "file_content": types.Schema( - type=types.Type.STRING, + "file_content": Schema( + type=Type.STRING, description="The complete, entire content of the file. A unified diff will be generated locally against the existing file.", ), - "diff_content": types.Schema( - type=types.Type.STRING, + "diff_content": Schema( + type=Type.STRING, description="The unified diff patch to apply (optional if file_path and file_content are provided).", ), }, ), ), - types.FunctionDeclaration( + FunctionDeclaration( name="read_file", description="Reads the content of a file. Only files within the current working directory or its subdirectories can be read.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "filepath": types.Schema( - type=types.Type.STRING, + "filepath": Schema( + type=Type.STRING, description="Path to the file to read.", ) }, required=["filepath"], ), ), - types.FunctionDeclaration( + FunctionDeclaration( name="list_directory", description="Reads the list of files and directories in a given path. Cannot list above the current working directory.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "directory_path": types.Schema( - type=types.Type.STRING, + "directory_path": Schema( + type=Type.STRING, description='The relative path to the directory to list. Defaults to "." for the current directory.', ) }, ), ), - types.FunctionDeclaration( + FunctionDeclaration( name="list_git_commits", description="Lists recent git commits in the repository. Use this to inspect history, identify commit IDs, or track changes to specific files.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "max_count": types.Schema( - type=types.Type.INTEGER, + "max_count": Schema( + type=Type.INTEGER, description="Maximum number of commits to list (default 10, max 50).", ), - "revision": types.Schema( - type=types.Type.STRING, + "revision": Schema( + type=Type.STRING, description="Git revision, branch, or range to list commits from (e.g. 'HEAD', 'main', 'HEAD~5..HEAD'). Defaults to 'HEAD'.", ), - "file_path": types.Schema( - type=types.Type.STRING, + "file_path": Schema( + type=Type.STRING, description="Optional relative file or directory path to filter commits affecting that path.", ), }, ), ), - types.FunctionDeclaration( + FunctionDeclaration( name="get_git_commit", description="Retrieves the commit message and diff/patch for a specific git commit ID. Use this to inspect what changed in a previous commit.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "commit_id": types.Schema( - type=types.Type.STRING, + "commit_id": Schema( + type=Type.STRING, description="The commit hash, short hash, or ref to inspect (e.g., 'a1b2c3d', 'HEAD~1').", ), - "file_path": types.Schema( - type=types.Type.STRING, + "file_path": Schema( + type=Type.STRING, description="Optional relative file path to view diff for that specific file only.", ), - "stat_only": types.Schema( - type=types.Type.BOOLEAN, + "stat_only": Schema( + type=Type.BOOLEAN, description="If true, returns only the diffstat instead of the full patch diff. Useful for large commits.", ), }, required=["commit_id"], ), ), - types.FunctionDeclaration( + FunctionDeclaration( name="git_grep", description="Searches repository code for a keyword or pattern using git grep and returns matching lines with surrounding context lines. Use this to quickly locate code definitions, usages, or snippets without reading whole files.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "pattern": types.Schema( - type=types.Type.STRING, + "pattern": Schema( + type=Type.STRING, description="The keyword or search pattern to find.", ), - "path": types.Schema( - type=types.Type.STRING, + "path": Schema( + type=Type.STRING, description="Optional relative file or directory pathspec to limit search (e.g. 'src/' or '*.py').", ), - "revision": types.Schema( - type=types.Type.STRING, + "revision": Schema( + type=Type.STRING, description="Optional git revision or commit ref to search (e.g. 'HEAD', 'main'). Defaults to working tree.", ), - "context_lines": types.Schema( - type=types.Type.INTEGER, + "context_lines": Schema( + type=Type.INTEGER, description="Number of context lines before and after match (default 2, max 10).", ), - "ignore_case": types.Schema( - type=types.Type.BOOLEAN, + "ignore_case": Schema( + type=Type.BOOLEAN, description="If true, perform case-insensitive search (default false).", ), }, required=["pattern"], ), ), - types.FunctionDeclaration( + FunctionDeclaration( name="build_code", description=build_desc, parameters=build_schema ), - types.FunctionDeclaration( + FunctionDeclaration( name="test_code", description=test_desc, parameters=test_schema ), - types.FunctionDeclaration( + FunctionDeclaration( name="fix_format", description=format_desc, parameters=format_schema ), ] @@ -473,7 +480,6 @@ def _process_command(self, req_id, params, conn): self.project_root = get_project_root() agent_config = self.agent_config or {} - api_key = load_api_key(agent_config) model = agent_config.get("model") verbose = False if isinstance(params, dict) and "verbose" in params: @@ -595,34 +601,46 @@ def _process_command(self, req_id, params, conn): "Do not attempt to work around command execution control by trying to add command execution in tests that is not actually testing the code." ) current_tools = get_agent_tools(self.project_root) - agent_config_obj = create_generation_config( + system_instruction_text = ( + "When explicitly requested to change code You act as an expert " + "autonomous coding agent and software engineer, and can access " + "tools and execute functions. " + "Normally although You are just an expert at returning general " + "information and avoid as much as possible using functions and " + "performing actions. " + "Your identity is Vimini, and you are integrated into the vimini project. " + "Follow these guidelines for optimal performance ONLY when " + "acting as a coding agent:\n" + "1. **Understand Context First:** Before proposing or applying any code changes, use `list_directory`, `read_file`, `git_grep`, `list_git_commits`, and `get_git_commit` tools to understand the repository structure, code contents, and previous git commit history. Never assume or guess code. You can look for AGENTS.md or CONTRIBUTING.md in the root tree if you need project-specific information to execute the task.\n" + "2. **Use the Patch Tool Correctly:** To modify or create files, use the `apply_patch` tool. You can provide the entire file contents using `file_path` and `file_content` (strongly preferred, as a unified diff will be generated locally to show the user) or provide a unified diff via `diff_content`. Use file paths relative to the project root.\n" + "3. **Patch Reliability:** If an `apply_patch` action fails due to a formatting or context mismatch, do not blindly retry the exact same patch. Re-read the file to obtain up-to-date content and send the entire file contents using `file_path` and `file_content`.\n" + f"{build_test_guideline}\n" + "5. **Limit Retries:** Limit multiple calls to `apply_patch` for the same file in a single response, in case of repeat failures for the same file prompt the user for guidance. If an `apply_patch` command is refused, evaluate the reason provided by the user and consider prompting the user for more instructions if the next steps are unclear.\n" + "6. **Be Concise:** Provide brief, clear explanations. Avoid unnecessary conversational filler." + "7. If this is a model that doesn't provide reasoning steps, provide brief statements on what is being worked on from time to time." + ) + create_generation_config( tools=current_tools, verbose=verbose, - system_instruction=( - "When explicitly requested to change code You act as an expert " - "autonomous coding agent and software engineer, and can access " - "tools and execute functions. " - "Normally although You are just an expert at returning general " - "information and avoid as much as possible using functions and " - "performing actions. " - "Your identity is Vimini, and you are integrated into the vimini project. " - "Follow these guidelines for optimal performance ONLY when " - "acting as a coding agent:\n" - "1. **Understand Context First:** Before proposing or applying any code changes, use `list_directory`, `read_file`, `git_grep`, `list_git_commits`, and `get_git_commit` tools to understand the repository structure, code contents, and previous git commit history. Never assume or guess code. You can look for AGENTS.md or CONTRIBUTING.md in the root tree if you need project-specific information to execute the task.\n" - "2. **Use the Patch Tool Correctly:** To modify or create files, use the `apply_patch` tool. You can provide the entire file contents using `file_path` and `file_content` (strongly preferred, as a unified diff will be generated locally to show the user) or provide a unified diff via `diff_content`. Use file paths relative to the project root.\n" - "3. **Patch Reliability:** If an `apply_patch` action fails due to a formatting or context mismatch, do not blindly retry the exact same patch. Re-read the file to obtain up-to-date content and send the entire file contents using `file_path` and `file_content`.\n" - f"{build_test_guideline}\n" - "5. **Limit Retries:** Limit multiple calls to `apply_patch` for the same file in a single response, in case of repeat failures for the same file prompt the user for guidance. If an `apply_patch` command is refused, evaluate the reason provided by the user and consider prompting the user for more instructions if the next steps are unclear.\n" - "6. **Be Concise:** Provide brief, clear explanations. Avoid unnecessary conversational filler." - "7. If this is a model that doesn't provide reasoning steps, provide brief statements on what is being worked on from time to time." - ), + system_instruction=system_instruction_text, disable_function_calling=False, ) - kwargs = {"model": model, "config": agent_config_obj} - if self.chat_history is not None: - kwargs["history"] = self.chat_history - self.session = self.client.chats.create(**kwargs) + if hasattr(self.client, "create_chat"): + self.session = self.client.create_chat( + model=model, + system_instruction=system_instruction_text, + tools=current_tools, + history=self.chat_history + if self.chat_history is not None + else None, + verbose=verbose, + ) + elif hasattr(self.client, "chats"): + kwargs = {"model": model} + if self.chat_history is not None: + kwargs["history"] = self.chat_history + self.session = self.client.chats.create(**kwargs) if not prompt: self.send_response(req_id, conn, result={"status": "ok", "text": ""}) @@ -633,10 +651,31 @@ def process_prompt_stream(current_prompt, current_req_id): try: response_stream = self.session.send_message_stream(current_prompt) for chunk in response_stream: - if ( - chunk.candidates - and chunk.candidates[0].content - and chunk.candidates[0].content.parts + if isinstance(chunk, StreamChunk): + if chunk.thought: + self.send_response( + current_req_id, + conn, + result={ + "status": "thought", + "thought": chunk.thought, + "verbose": verbose, + }, + ) + if chunk.text: + self.send_response( + current_req_id, + conn, + result={"status": "chunk", "text": chunk.text}, + ) + if chunk.tool_calls: + for tc in chunk.tool_calls: + pending_tool_calls.append(tc) + elif ( + getattr(chunk, "candidates", None) + and chunk.candidates + and getattr(chunk.candidates[0], "content", None) + and getattr(chunk.candidates[0].content, "parts", None) ): modified_text = "" for part in chunk.candidates[0].content.parts: @@ -664,7 +703,7 @@ def process_prompt_stream(current_prompt, current_req_id): conn, result={"status": "chunk", "text": modified_text}, ) - elif chunk.text: + elif getattr(chunk, "text", None): self.send_response( current_req_id, conn, @@ -679,10 +718,16 @@ def process_prompt_stream(current_prompt, current_req_id): current_req_id, conn, result={"status": "error", "error": str(e)} ) if self.session: - try: - self.chat_history = self.session.get_history() - except Exception: - pass + if ( + hasattr(self.session, "has_history") + and not self.session.has_history() + ): + self.chat_history = None + elif hasattr(self.session, "get_history"): + try: + self.chat_history = self.session.get_history() + except Exception: + pass self.session = None return False @@ -692,6 +737,7 @@ def process_prompt_stream(current_prompt, current_req_id): for tool_call in pending_tool_calls: args_dict = dict(tool_call.args) if tool_call.args else {} temp_file_path = None + call_id = getattr(tool_call, "call_id", None) logger.info(f"Chat[{req_id}]: tool use requested: {tool_call.name}") if tool_call.name == "apply_patch": files_to_diff = [] @@ -752,23 +798,21 @@ def process_prompt_stream(current_prompt, current_req_id): if diff_errors: responses.append( - types.Part.from_function_response( + ToolResponse( name=tool_call.name, - response={ - "result": f"Patch generation failed:\n" - + "\n".join(diff_errors) - }, + response=f"Patch generation failed:\n" + + "\n".join(diff_errors), + call_id=call_id, ) ) continue if not diff_parts: responses.append( - types.Part.from_function_response( + ToolResponse( name=tool_call.name, - response={ - "result": "The provided file content is identical to the existing file; no modifications were detected." - }, + response="The provided file content is identical to the existing file; no modifications were detected.", + call_id=call_id, ) ) continue @@ -777,11 +821,10 @@ def process_prompt_stream(current_prompt, current_req_id): if not diff_content: responses.append( - types.Part.from_function_response( + ToolResponse( name=tool_call.name, - response={ - "result": "Patch failed: Neither valid 'file_content' (with 'file_path') nor 'diff_content' was provided." - }, + response="Patch failed: Neither valid 'file_content' (with 'file_path') nor 'diff_content' was provided.", + call_id=call_id, ) ) continue @@ -802,11 +845,10 @@ def process_prompt_stream(current_prompt, current_req_id): except Exception: pass responses.append( - types.Part.from_function_response( + ToolResponse( name=tool_call.name, - response={ - "result": f"Patch validation failed: {err_msg}" - }, + response=f"Patch validation failed: {err_msg}", + call_id=call_id, ) ) continue @@ -860,11 +902,10 @@ def process_prompt_stream(current_prompt, current_req_id): result={"status": "chunk", "text": msg}, ) responses.append( - types.Part.from_function_response( + ToolResponse( name=tool_call.name, - response={ - "result": f"The tool '{tool_call.name}' is not available for this project because no {tool_label} command is configured in project options." - }, + response=f"The tool '{tool_call.name}' is not available for this project because no {tool_label} command is configured in project options.", + call_id=call_id, ) ) continue @@ -885,11 +926,10 @@ def process_prompt_stream(current_prompt, current_req_id): }, ) responses.append( - types.Part.from_function_response( + ToolResponse( name=tool_call.name, - response={ - "result": f"Command rejected: {res_or_err}" - }, + response=f"Command rejected: {res_or_err}", + call_id=call_id, ) ) continue @@ -967,8 +1007,10 @@ def process_prompt_stream(current_prompt, current_req_id): "Tool execution cancelled or rejected by user." ) responses.append( - types.Part.from_function_response( - name=tool_call.name, response={"result": result_text} + ToolResponse( + name=tool_call.name, + response={"result": result_text}, + call_id=call_id, ) ) elif tool_call.name == "read_file": @@ -980,8 +1022,10 @@ def process_prompt_stream(current_prompt, current_req_id): "Tool execution cancelled or rejected by user." ) responses.append( - types.Part.from_function_response( - name=tool_call.name, response={"result": result_text} + ToolResponse( + name=tool_call.name, + response={"result": result_text}, + call_id=call_id, ) ) elif tool_call.name == "list_git_commits": @@ -1000,8 +1044,10 @@ def process_prompt_stream(current_prompt, current_req_id): "Tool execution cancelled or rejected by user." ) responses.append( - types.Part.from_function_response( - name=tool_call.name, response={"result": result_text} + ToolResponse( + name=tool_call.name, + response={"result": result_text}, + call_id=call_id, ) ) elif tool_call.name == "get_git_commit": @@ -1020,8 +1066,10 @@ def process_prompt_stream(current_prompt, current_req_id): "Tool execution cancelled or rejected by user." ) responses.append( - types.Part.from_function_response( - name=tool_call.name, response={"result": result_text} + ToolResponse( + name=tool_call.name, + response={"result": result_text}, + call_id=call_id, ) ) elif tool_call.name == "git_grep": @@ -1044,8 +1092,10 @@ def process_prompt_stream(current_prompt, current_req_id): "Tool execution cancelled or rejected by user." ) responses.append( - types.Part.from_function_response( - name=tool_call.name, response={"result": result_text} + ToolResponse( + name=tool_call.name, + response={"result": result_text}, + call_id=call_id, ) ) elif tool_call.name == "apply_patch": @@ -1082,8 +1132,10 @@ def process_prompt_stream(current_prompt, current_req_id): patch_result = "Apply patch command was refused. Do not retry and instead prompt the user for more instructions." responses.append( - types.Part.from_function_response( - name=tool_call.name, response={"result": patch_result} + ToolResponse( + name=tool_call.name, + response={"result": patch_result}, + call_id=call_id, ) ) elif tool_call.name in ("build_code", "test_code", "fix_format"): @@ -1111,8 +1163,10 @@ def process_prompt_stream(current_prompt, current_req_id): "Tool execution cancelled or rejected by user." ) responses.append( - types.Part.from_function_response( - name=tool_call.name, response={"result": result_text} + ToolResponse( + name=tool_call.name, + response={"result": result_text}, + call_id=call_id, ) ) else: @@ -1121,8 +1175,10 @@ def process_prompt_stream(current_prompt, current_req_id): else: result_text = "Tool execution rejected by user." responses.append( - types.Part.from_function_response( - name=tool_call.name, response={"result": result_text} + ToolResponse( + name=tool_call.name, + response={"result": result_text}, + call_id=call_id, ) ) @@ -1132,10 +1188,17 @@ def process_prompt_stream(current_prompt, current_req_id): if not process_prompt_stream(prompt, req_id): return - try: - self.chat_history = self.session.get_history() - except Exception: - pass + if ( + self.session + and hasattr(self.session, "has_history") + and not self.session.has_history() + ): + self.chat_history = None + elif self.session and hasattr(self.session, "get_history"): + try: + self.chat_history = self.session.get_history() + except Exception: + pass if not self.running: return self.send_response(req_id, conn, result={"status": "done"}) diff --git a/python3/vimini/agent/code.py b/python3/vimini/agent/code.py index 5769b81..a043fff 100644 --- a/python3/vimini/agent/code.py +++ b/python3/vimini/agent/code.py @@ -3,10 +3,13 @@ import logging import re import difflib -from google.genai import types from vimini.agent.comms import CommSession -from vimini.common.genai import get_client, create_generation_config -from vimini.common.context import upload_context_files +from vimini.common.backends import ( + get_client, + Schema, + Type, + StreamChunk, +) logger = logging.getLogger("vimini_agent") @@ -123,38 +126,33 @@ def _process_command(self, req_id, params, conn): try: client = get_client(config=agent_config) - file_object_schema = types.Schema( - type=types.Type.OBJECT, + file_object_schema = Schema( + type=Type.OBJECT, properties={ - "file_path": types.Schema( - type=types.Type.STRING, + "file_path": Schema( + type=Type.STRING, description="The full path of the file relative to the project directory.", ), - "file_type": types.Schema( - type=types.Type.STRING, + "file_type": Schema( + type=Type.STRING, description="The content type. Use 'text/plain' for the full file content or 'text/x-diff' for a patch in the unified diff format.", ), - "file_content": types.Schema( - type=types.Type.STRING, + "file_content": Schema( + type=Type.STRING, description="The new, complete source code for the file, or a patch in the unified diff format, corresponding to the file_type.", ), }, required=["file_path", "file_type", "file_content"], ) - multi_file_output_schema = types.Schema( - type=types.Type.OBJECT, - properties={ - "files": types.Schema( - type=types.Type.ARRAY, items=file_object_schema - ) - }, + multi_file_output_schema = Schema( + type=Type.OBJECT, + properties={"files": Schema(type=Type.ARRAY, items=file_object_schema)}, required=["files"], ) uploaded_files = ( - upload_context_files( + client.upload_context_files( logger, - client, file_paths_to_include=file_paths_to_include, project_root=project_root, buffers=buffers, @@ -162,7 +160,9 @@ def _process_command(self, req_id, params, conn): ) or [] ) - context_file_names = [f.display_name for f in uploaded_files] + context_file_names = [ + f.display_name for f in uploaded_files if hasattr(f, "display_name") + ] processing_errors = [] file_list_str = "\n".join( @@ -195,21 +195,37 @@ def _process_command(self, req_id, params, conn): *uploaded_files, ] - generation_config = create_generation_config( + response_stream = client.generate_content_stream( + model=model, + prompt=full_prompt, verbose=verbose, - response_mime_type="application/json", response_schema=multi_file_output_schema, - disable_function_calling=True, - ) - - response_stream = client.models.generate_content_stream( - model=model, contents=full_prompt, config=generation_config ) json_aggregator = "" for chunk in response_stream: - if hasattr(chunk, "candidates") and chunk.candidates: + if isinstance(chunk, StreamChunk): + if chunk.thought: + self.send_response( + req_id, + conn, + result={ + "status": "thought", + "thought": chunk.thought, + }, + ) + if chunk.text: + json_aggregator += chunk.text + self.send_response( + req_id, + conn, + result={ + "status": "chunk", + "text": chunk.text, + }, + ) + elif hasattr(chunk, "candidates") and chunk.candidates: candidate = chunk.candidates[0] if candidate.content and candidate.content.parts: for part in candidate.content.parts: diff --git a/python3/vimini/agent/review.py b/python3/vimini/agent/review.py index 4b73e7d..c1196fb 100644 --- a/python3/vimini/agent/review.py +++ b/python3/vimini/agent/review.py @@ -3,9 +3,17 @@ import re import json import subprocess -from google.genai import types from vimini.agent.comms import CommSession -from vimini.common.genai import get_client, create_generation_config +from vimini.common.backends import ( + get_client, + Tool, + FunctionDeclaration, + Schema, + Type, + ToolResponse, + BaseClient, + StreamChunk, +) from vimini.common.util import ( list_directory, read_file, @@ -21,102 +29,102 @@ DEFAULT_MAX_TURNS = 30 review_tools = [ - types.Tool( + Tool( function_declarations=[ - types.FunctionDeclaration( + FunctionDeclaration( name="read_file", description="Reads the content of a file. Only files within the current working directory or its subdirectories can be read. Use sparingly and only as needed.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "filepath": types.Schema( - type=types.Type.STRING, + "filepath": Schema( + type=Type.STRING, description="Path to the file to read.", ) }, required=["filepath"], ), ), - types.FunctionDeclaration( + FunctionDeclaration( name="list_directory", description="Reads the list of files and directories in a given path. Cannot list above the current working directory. Use sparingly and only as needed.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "directory_path": types.Schema( - type=types.Type.STRING, + "directory_path": Schema( + type=Type.STRING, description='The relative path to the directory to list. Defaults to "." for the current directory.', ) }, ), ), - types.FunctionDeclaration( + FunctionDeclaration( name="list_git_commits", description="Lists recent git commits in the repository. Use this to inspect history, identify commit IDs, or track changes to specific files.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "max_count": types.Schema( - type=types.Type.INTEGER, + "max_count": Schema( + type=Type.INTEGER, description="Maximum number of commits to list (default 10, max 50).", ), - "revision": types.Schema( - type=types.Type.STRING, + "revision": Schema( + type=Type.STRING, description="Git revision, branch, or range to list commits from (e.g. 'HEAD', 'main', 'HEAD~5..HEAD'). Defaults to 'HEAD'.", ), - "file_path": types.Schema( - type=types.Type.STRING, + "file_path": Schema( + type=Type.STRING, description="Optional relative file or directory path to filter commits affecting that path.", ), }, ), ), - types.FunctionDeclaration( + FunctionDeclaration( name="get_git_commit", description="Retrieves the commit message and diff/patch for a specific git commit ID. Use this to inspect what changed in a previous commit.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "commit_id": types.Schema( - type=types.Type.STRING, + "commit_id": Schema( + type=Type.STRING, description="The commit hash, short hash, or ref to inspect (e.g., 'a1b2c3d', 'HEAD~1').", ), - "file_path": types.Schema( - type=types.Type.STRING, + "file_path": Schema( + type=Type.STRING, description="Optional relative file path to view diff for that specific file only.", ), - "stat_only": types.Schema( - type=types.Type.BOOLEAN, + "stat_only": Schema( + type=Type.BOOLEAN, description="If true, returns only the diffstat instead of the full patch diff. Useful for large commits.", ), }, required=["commit_id"], ), ), - types.FunctionDeclaration( + FunctionDeclaration( name="git_grep", description="Searches repository code for a keyword or pattern using git grep and returns matching lines with surrounding context lines. Use this to quickly locate code definitions, usages, or snippets without reading whole files.", - parameters=types.Schema( - type=types.Type.OBJECT, + parameters=Schema( + type=Type.OBJECT, properties={ - "pattern": types.Schema( - type=types.Type.STRING, + "pattern": Schema( + type=Type.STRING, description="The keyword or search pattern to find.", ), - "path": types.Schema( - type=types.Type.STRING, + "path": Schema( + type=Type.STRING, description="Optional relative file or directory pathspec to limit search (e.g. 'src/' or '*.py').", ), - "revision": types.Schema( - type=types.Type.STRING, + "revision": Schema( + type=Type.STRING, description="Optional git revision or commit ref to search (e.g. 'HEAD', 'main'). Defaults to working tree.", ), - "context_lines": types.Schema( - type=types.Type.INTEGER, + "context_lines": Schema( + type=Type.INTEGER, description="Number of context lines before and after match (default 2, max 10).", ), - "ignore_case": types.Schema( - type=types.Type.BOOLEAN, + "ignore_case": Schema( + type=Type.BOOLEAN, description="If true, perform case-insensitive search (default false).", ), }, @@ -202,7 +210,30 @@ def _execute_review_stream( if not session._check_running(req_id, conn): break - if ( + if isinstance(chunk, StreamChunk): + if chunk.thought and not save: + session.send_response( + req_id, + conn, + result={ + "status": "thought", + "thought": chunk.thought, + "verbose": verbose, + }, + ) + if chunk.text: + if save: + accumulator.append(chunk.text) + else: + session.send_response( + req_id, + conn, + result={"status": "chunk", "text": chunk.text}, + ) + if chunk.tool_calls: + for tc in chunk.tool_calls: + pending_tool_calls.append(tc) + elif ( hasattr(chunk, "candidates") and chunk.candidates and chunk.candidates[0].content @@ -266,6 +297,7 @@ def _execute_review_stream( args_dict = dict(tool_call.args) if tool_call.args else {} logger.info(f"Review tool call: {tool_call.name}({args_dict})") args_str = json.dumps(args_dict) if args_dict else "" + call_id = getattr(tool_call, "call_id", None) if save: commit_prefix = f"Commit {commit_sha[:7]} " if commit_sha else "" @@ -366,8 +398,10 @@ def _execute_review_stream( result_text = raw_result responses.append( - types.Part.from_function_response( - name=tool_call.name, response={"result": result_text} + ToolResponse( + name=tool_call.name, + response={"result": result_text}, + call_id=call_id, ) ) @@ -516,16 +550,17 @@ def _handle_batch_review(self, req_id, params, conn): max_turns=max_turns, ) - generation_config = create_generation_config( - tools=review_tools, - verbose=verbose, - disable_function_calling=False, - ) - try: - chat = client.chats.create( - model=model, config=generation_config - ) + if isinstance(client, BaseClient) or not hasattr( + client, "chats" + ): + chat = client.create_chat( + model=model, + tools=review_tools, + verbose=verbose, + ) + else: + chat = client.chats.create(model=model, config=None) content = _execute_review_stream( self, @@ -661,13 +696,14 @@ def _handle_interactive_review(self, req_id, params, conn): max_turns=max_turns, ) - generation_config = create_generation_config( - tools=review_tools, - verbose=verbose, - disable_function_calling=False, - ) - - chat = client.chats.create(model=model, config=generation_config) + if isinstance(client, BaseClient) or not hasattr(client, "chats"): + chat = client.create_chat( + model=model, + tools=review_tools, + verbose=verbose, + ) + else: + chat = client.chats.create(model=model, config=None) with temporary_git_worktree(project_root, worktree_ref) as active_root: _execute_review_stream( diff --git a/python3/vimini/agent/server.py b/python3/vimini/agent/server.py index d01f195..bbec234 100644 --- a/python3/vimini/agent/server.py +++ b/python3/vimini/agent/server.py @@ -16,7 +16,7 @@ import logging import signal import atexit -from vimini.common.genai import get_client, load_api_key, create_generation_config +from vimini.common.backends import get_client MAX_RECEIVE_BUFFER_SIZE = 1024 * 1024 AGENT_CONFIG = {} @@ -65,38 +65,16 @@ def execute_function(req_id, method, params, result_queue, conn): result = {"status": "ok"} elif method == "list_models": client = get_client(config=AGENT_CONFIG) - models_iter = client.models.list() - models = [] - for m in models_iter: - actions = ( - getattr(m, "supported_actions", None) - or getattr(m, "supported_generation_methods", None) - or [] - ) - if isinstance(actions, (list, tuple, set)): - actions = list(actions) - else: - actions = [] - models.append( - { - "name": m.name, - "display_name": getattr(m, "display_name", m.name), - "description": getattr(m, "description", "") or "", - "supported_actions": actions, - } - ) + models = client.list_models() result = {"status": "ok", "models": models} elif method == "commit": client = get_client(config=AGENT_CONFIG) model = AGENT_CONFIG.get("model") prompt = params.get("prompt", "") if isinstance(params, dict) else "" - config = create_generation_config(disable_function_calling=True) - response = client.models.generate_content( - model=model, contents=prompt, config=config - ) + text = client.generate_content(prompt=prompt, model=model) result = { "status": "ok", - "text": response.text, + "text": text, "repo_path": params.get("repo_path") if isinstance(params, dict) else None, @@ -121,11 +99,8 @@ def execute_function(req_id, method, params, result_queue, conn): client = get_client(config=AGENT_CONFIG) model = AGENT_CONFIG.get("model") prompt = params.get("prompt", "") if isinstance(params, dict) else "" - config = create_generation_config(disable_function_calling=True) - response = client.models.generate_content( - model=model, contents=prompt, config=config - ) - result = {"status": "ok", "text": response.text} + text = client.generate_content(prompt=prompt, model=model) + result = {"status": "ok", "text": text} else: result = {"status": "ok"} response = [ diff --git a/python3/vimini/common/backends/__init__.py b/python3/vimini/common/backends/__init__.py index 18110c8..7b5f99d 100644 --- a/python3/vimini/common/backends/__init__.py +++ b/python3/vimini/common/backends/__init__.py @@ -3,9 +3,13 @@ from vimini.common.backends.base import ( BaseChatSession, BaseClient, + FunctionDeclaration, + Schema, StreamChunk, + Tool, ToolCall, ToolResponse, + Type, ) from vimini.common.backends.genai_backend import GenAIBackend, GenAIChatSession from vimini.common.backends.proxy_backend import ( @@ -25,36 +29,53 @@ def get_backend( backend_name: Optional[str] = None, config: Optional[Dict[str, Any]] = None, config_path: Optional[str] = None, + api_key: Optional[str] = None, + api_key_file: Optional[str] = None, ) -> BaseClient: """ Factory function returning a BaseClient instance for the requested or active backend. + Confines all backend selection logic within generic backend methods. """ - if config is None: - config = load_global_config(config_path=config_path) + global_cfg = load_global_config(config_path=config_path) + backends = global_cfg.get("backends", {}) - backends = config.get("backends", {}) + if backend_name is None and isinstance(config, dict): + backend_name = config.get("backend") if backend_name is None: - backend_name = get_active_backend(config) + backend_name = get_active_backend(global_cfg) if backend_name not in backends: - available = list(backends.keys()) - raise ValueError( - f"Backend '{backend_name}' not found in configuration. Available: {available}" - ) + # Check if config itself has backends dictionary + if isinstance(config, dict) and "backends" in config: + backends = config.get("backends", {}) + if backend_name not in backends: + available = list(backends.keys()) + raise ValueError( + f"Backend '{backend_name}' not found in configuration. Available: {available}" + ) + + backend_cfg = dict(backends[backend_name]) + if isinstance(config, dict): + # Allow runtime overrides from caller config if present + for k in ("api_key", "api_key_file", "base_url", "headers"): + if k in config and config[k] is not None: + backend_cfg[k] = config[k] - backend_cfg = backends[backend_name] api = backend_cfg.get("api", "genai") - if api == "genai": + if api_key is None: api_key = load_backend_api_key(backend_cfg) + + target_key_file = api_key_file or backend_cfg.get("api_key_file") + + if api == "genai": return GenAIBackend( api_key=api_key, - api_key_file=backend_cfg.get("api_key_file"), + api_key_file=target_key_file, config=backend_cfg, ) elif api == "openai": - api_key = load_backend_api_key(backend_cfg) return ProxyBackend( config=backend_cfg, api_key=api_key, @@ -65,12 +86,36 @@ def get_backend( raise ValueError(f"Unsupported API protocol '{api}' for backend '{backend_name}'.") +def get_client( + config: Optional[Dict[str, Any]] = None, + backend_name: Optional[str] = None, + config_path: Optional[str] = None, + api_key: Optional[str] = None, + api_key_file: Optional[str] = None, +) -> BaseClient: + """ + Generic client accessor for agent and frontend code. + Confines all backend selection into generic backend methods. + """ + return get_backend( + backend_name=backend_name, + config=config, + config_path=config_path, + api_key=api_key, + api_key_file=api_key_file, + ) + + __all__ = [ "BaseChatSession", "BaseClient", + "FunctionDeclaration", + "Schema", "StreamChunk", + "Tool", "ToolCall", "ToolResponse", + "Type", "GenAIBackend", "GenAIChatSession", "ProxyBackend", @@ -78,4 +123,5 @@ def get_backend( "convert_schema_to_openai", "convert_tools_to_openai", "get_backend", + "get_client", ] diff --git a/python3/vimini/common/backends/base.py b/python3/vimini/common/backends/base.py index 042ecae..3e1ea99 100644 --- a/python3/vimini/common/backends/base.py +++ b/python3/vimini/common/backends/base.py @@ -3,6 +3,57 @@ from typing import Any, Dict, Iterator, List, Optional +class TypeEnum: + def __init__(self, name: str): + self.name = name + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f"Type.{self.name}" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, str): + return self.name.lower() == other.lower() + if hasattr(other, "name"): + return self.name.lower() == str(other.name).lower() + return False + + def __hash__(self) -> int: + return hash(self.name.lower()) + + +class Type: + OBJECT = TypeEnum("OBJECT") + STRING = TypeEnum("STRING") + INTEGER = TypeEnum("INTEGER") + BOOLEAN = TypeEnum("BOOLEAN") + ARRAY = TypeEnum("ARRAY") + + +@dataclass +class Schema: + type: str = Type.OBJECT + description: Optional[str] = None + properties: Optional[Dict[str, Any]] = None + required: Optional[List[str]] = None + enum: Optional[List[str]] = None + items: Optional[Any] = None + + +@dataclass +class FunctionDeclaration: + name: str + description: Optional[str] = None + parameters: Optional[Schema] = None + + +@dataclass +class Tool: + function_declarations: List[FunctionDeclaration] = field(default_factory=list) + + @dataclass class ToolCall: name: str @@ -85,6 +136,8 @@ def generate_content_stream( prompt: Any, model: str, system_instruction: Optional[str] = None, + verbose: bool = False, + response_schema: Optional[Any] = None, config: Optional[Any] = None, ) -> Iterator[StreamChunk]: """ @@ -106,3 +159,16 @@ def create_chat( Creates and returns a BaseChatSession for multi-turn chat. """ pass + + def upload_context_files( + self, + logger: Any, + file_paths_to_include: Optional[List[str]] = None, + project_root: Optional[str] = None, + buffers: Optional[List[Any]] = None, + display_cb: Optional[Any] = None, + ) -> List[Any]: + """ + Uploads or processes context files if supported by backend. + """ + return [] diff --git a/python3/vimini/common/backends/genai_backend.py b/python3/vimini/common/backends/genai_backend.py index 15f2d42..fe3dfcf 100644 --- a/python3/vimini/common/backends/genai_backend.py +++ b/python3/vimini/common/backends/genai_backend.py @@ -4,7 +4,10 @@ from vimini.common.backends.base import ( BaseChatSession, BaseClient, + FunctionDeclaration, + Schema, StreamChunk, + Tool, ToolCall, ToolResponse, ) @@ -17,6 +20,114 @@ logger = logging.getLogger("vimini_agent") +def _to_genai_schema(schema: Any) -> Any: + if schema is None: + return types.Schema(type=types.Type.OBJECT) + if isinstance(schema, types.Schema): + return schema + if isinstance(schema, Schema): + type_str = str(schema.type).upper().split(".")[-1] + genai_type = getattr(types.Type, type_str, types.Type.OBJECT) + props = None + if schema.properties: + props = {k: _to_genai_schema(v) for k, v in schema.properties.items()} + items = None + if schema.items: + items = _to_genai_schema(schema.items) + return types.Schema( + type=genai_type, + description=schema.description, + properties=props, + required=schema.required, + enum=schema.enum, + items=items, + ) + if isinstance(schema, dict): + type_val = schema.get("type", "object") + type_str = str(type_val).upper().split(".")[-1] + genai_type = getattr(types.Type, type_str, types.Type.OBJECT) + props = None + if "properties" in schema and isinstance(schema["properties"], dict): + props = {k: _to_genai_schema(v) for k, v in schema["properties"].items()} + items = None + if "items" in schema: + items = _to_genai_schema(schema["items"]) + return types.Schema( + type=genai_type, + description=schema.get("description"), + properties=props, + required=schema.get("required"), + enum=schema.get("enum"), + items=items, + ) + return types.Schema(type=types.Type.OBJECT) + + +def _to_genai_tools(tools: Any) -> Optional[List[Any]]: + if not tools: + return None + genai_tools = [] + tool_list = tools if isinstance(tools, (list, tuple)) else [tools] + for t in tool_list: + if isinstance(t, types.Tool): + genai_tools.append(t) + elif isinstance(t, Tool): + fds = [] + for fd in t.function_declarations: + if isinstance(fd, types.FunctionDeclaration): + fds.append(fd) + else: + fds.append( + types.FunctionDeclaration( + name=fd.name, + description=fd.description or "", + parameters=_to_genai_schema(fd.parameters), + ) + ) + genai_tools.append(types.Tool(function_declarations=fds)) + elif isinstance(t, FunctionDeclaration): + genai_tools.append( + types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name=t.name, + description=t.description or "", + parameters=_to_genai_schema(t.parameters), + ) + ] + ) + ) + elif isinstance(t, types.FunctionDeclaration): + genai_tools.append(types.Tool(function_declarations=[t])) + elif isinstance(t, dict): + if t.get("type") == "function" and "function" in t: + fn = t["function"] + genai_tools.append( + types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name=fn.get("name", ""), + description=fn.get("description", ""), + parameters=_to_genai_schema(fn.get("parameters")), + ) + ] + ) + ) + elif "name" in t: + genai_tools.append( + types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name=t["name"], + description=t.get("description", ""), + parameters=_to_genai_schema(t.get("parameters")), + ) + ] + ) + ) + return genai_tools if genai_tools else None + + class GenAIChatSession(BaseChatSession): def __init__( self, @@ -84,7 +195,13 @@ def send_message_stream( if hasattr(part, "function_call") and part.function_call: fc = part.function_call args_dict = dict(fc.args) if getattr(fc, "args", None) else {} - tool_calls.append(ToolCall(name=fc.name, args=args_dict)) + tool_calls.append( + ToolCall( + name=fc.name, + args=args_dict, + call_id=getattr(fc, "id", None), + ) + ) elif getattr(part, "thought", False): thought += getattr(part, "text", "") or "" elif hasattr(part, "text") and part.text: @@ -151,10 +268,25 @@ def generate_content_stream( prompt: Any, model: str, system_instruction: Optional[str] = None, + verbose: bool = False, + response_schema: Optional[Any] = None, config: Optional[Any] = None, ) -> Iterator[StreamChunk]: if config is None: - config = create_generation_config(system_instruction=system_instruction) + schema_arg = ( + _to_genai_schema(response_schema) + if response_schema is not None + else None + ) + config = create_generation_config( + verbose=verbose, + system_instruction=system_instruction, + response_mime_type="application/json" + if response_schema is not None + else None, + response_schema=schema_arg, + disable_function_calling=True, + ) response_stream = self.client.models.generate_content_stream( model=model, contents=prompt, config=config ) @@ -186,12 +318,35 @@ def create_chat( config: Optional[Any] = None, ) -> BaseChatSession: if config is None: + genai_tools = _to_genai_tools(tools) config = create_generation_config( - tools=tools, + tools=genai_tools, verbose=verbose, system_instruction=system_instruction, - disable_function_calling=tools is None, + disable_function_calling=genai_tools is None, ) return GenAIChatSession( self.client, model=model, config=config, history=history ) + + def upload_context_files( + self, + logger: Any, + file_paths_to_include: Optional[List[str]] = None, + project_root: Optional[str] = None, + buffers: Optional[List[Any]] = None, + display_cb: Optional[Any] = None, + ) -> List[Any]: + from vimini.common.context import upload_context_files + + return ( + upload_context_files( + logger, + self.client, + file_paths_to_include=file_paths_to_include, + project_root=project_root, + buffers=buffers, + display_cb=display_cb, + ) + or [] + ) diff --git a/python3/vimini/common/backends/proxy_backend.py b/python3/vimini/common/backends/proxy_backend.py index 2ca2b8c..cf0960f 100644 --- a/python3/vimini/common/backends/proxy_backend.py +++ b/python3/vimini/common/backends/proxy_backend.py @@ -377,6 +377,8 @@ def generate_content( prompt: str, model: str, system_instruction: Optional[str] = None, + verbose: bool = False, + response_schema: Optional[Any] = None, config: Optional[Any] = None, ) -> str: messages: List[Dict[str, Any]] = [] @@ -401,6 +403,8 @@ def generate_content_stream( prompt: Any, model: str, system_instruction: Optional[str] = None, + verbose: bool = False, + response_schema: Optional[Any] = None, config: Optional[Any] = None, ) -> Iterator[StreamChunk]: messages: List[Dict[str, Any]] = [] @@ -415,6 +419,8 @@ def generate_content_stream( "messages": messages, "stream": True, } + if response_schema is not None: + payload["response_format"] = {"type": "json_object"} resp = self._make_request("/chat/completions", data=payload, stream=True) for event_data in self._read_sse_stream(resp): choices = event_data.get("choices", []) diff --git a/python3/vimini/main.py b/python3/vimini/main.py index 013d5b7..d1b4dad 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -1,6 +1,5 @@ import vim import os, subprocess, shlex, textwrap, json, tempfile, time -from google.genai import types from vimini import util from vimini.util import process_queue, get_model_name from vimini.autocomplete import autocomplete, cancel_autocomplete @@ -92,11 +91,26 @@ def send_setup(): """ handler = SetupChannelHandler("setup") register_handler("setup", handler) + backend_name = getattr(util, "_BACKEND", None) + if not backend_name: + try: + backend_name = vim.eval("get(g:, 'vimini_backend', '')") + except Exception: + backend_name = None + if not backend_name: + try: + from vimini.common.config import load_global_config, get_active_backend + + cfg = load_global_config() + backend_name = get_active_backend(cfg) + except Exception: + backend_name = "Gemini" req = { "jsonrpc": "2.0", "id": "setup", "method": "setup", "params": { + "backend": backend_name, "api_key_file": util._API_KEY_FILE, "model": util._MODEL, }, diff --git a/python3/vimini/util.py b/python3/vimini/util.py index 9292297..e1c02a5 100644 --- a/python3/vimini/util.py +++ b/python3/vimini/util.py @@ -2,8 +2,6 @@ import os, subprocess, time, io, logging, inspect, json import threading import queue -from google import genai -from google.genai import types from vimini.common.genai import ( get_client as create_genai_client, load_api_key, diff --git a/tests/test_agent_backend_integration.py b/tests/test_agent_backend_integration.py new file mode 100644 index 0000000..0d857f0 --- /dev/null +++ b/tests/test_agent_backend_integration.py @@ -0,0 +1,128 @@ +import os +import sys +import queue +import pytest +from unittest.mock import MagicMock, patch + +sys.path.insert( + 0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "python3")) +) + +from vimini.common.backends.base import ( + BaseChatSession, + BaseClient, + StreamChunk, + ToolCall, +) +from vimini.agent.server import execute_function +from vimini.agent.chat import ChatSession + + +class DummyProxyChatSession(BaseChatSession): + def __init__(self): + self._history_queried = False + + def send_message_stream(self, prompt_or_tool_responses): + yield StreamChunk(text="Response from ProxyAPI") + + def has_history(self): + return False + + def get_history(self): + self._history_queried = True + return [] + + +class DummyProxyClient(BaseClient): + def __init__(self): + self.chat_session = DummyProxyChatSession() + + def list_models(self): + return [ + { + "name": "gpt-4o", + "display_name": "GPT-4o", + "description": "", + "supported_actions": ["generateContent"], + } + ] + + def generate_content(self, prompt, model, system_instruction=None, config=None): + return f"Generated for {prompt}" + + def generate_content_stream( + self, + prompt, + model, + system_instruction=None, + verbose=False, + response_schema=None, + config=None, + ): + yield StreamChunk(text='{"files": []}') + + def create_chat( + self, + model, + system_instruction=None, + tools=None, + history=None, + verbose=False, + config=None, + ): + return self.chat_session + + +def test_server_execute_function_uses_get_client(): + dummy_client = DummyProxyClient() + result_q = queue.Queue() + + with patch("vimini.agent.server.get_client", return_value=dummy_client): + # 1. list_models + execute_function("req1", "list_models", {}, result_q, "conn1") + conn, resp = result_q.get() + assert resp[1]["result"]["status"] == "ok" + assert resp[1]["result"]["models"][0]["name"] == "gpt-4o" + + # 2. commit + execute_function("req2", "commit", {"prompt": "Commit msg"}, result_q, "conn1") + conn, resp = result_q.get() + assert resp[1]["result"]["status"] == "ok" + assert resp[1]["result"]["text"] == "Generated for Commit msg" + + # 3. autocomplete + execute_function( + "req3", "autocomplete", {"prompt": "def func():"}, result_q, "conn1" + ) + conn, resp = result_q.get() + assert resp[1]["result"]["status"] == "ok" + assert resp[1]["result"]["text"] == "Generated for def func():" + + +def test_chat_session_honors_has_history_false(): + dummy_client = DummyProxyClient() + result_q = queue.Queue() + + session = ChatSession( + req_id="chat1", + result_queue=result_q, + agent_config={"backend": "Custom 1", "model": "gpt-4o"}, + ) + session.send_response = MagicMock() + + with patch("vimini.agent.chat.get_client", return_value=dummy_client): + session._process_command("chat1", {"prompt": "Hello"}, "mock_conn") + + # ProxyChatSession has has_history() == False + assert session.chat_history is None + # get_history() should not have been called + assert dummy_client.chat_session._history_queried is False + + # Verify streamed response + chunk_calls = [ + call + for call in session.send_response.call_args_list + if call[1].get("result", {}).get("status") == "chunk" + ] + assert len(chunk_calls) == 1 + assert chunk_calls[0][1]["result"]["text"] == "Response from ProxyAPI" From 35ba5bcb0cce363b54abaee68ff4e85dd3bc3052 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 21:36:35 -0400 Subject: [PATCH 04/19] Add runtime backend switching and Vim UI commands Implement the `:ViminiBackend` command with completion to view or switch active provider backends at runtime without restarting Vim. Update the model listing buffer to display active backend names and support single- group listings for OpenAI-compatible proxies, and include backend info in status buffer output. Update documentation and add test coverage. Assisted-by: Gemini:Gemini 3.8 Flash Signed-off-by: Simo Sorce --- PROXY-API.md | 22 +-- README.md | 23 +++ plugin/vimini.vim | 30 +++- python3/vimini/common/backends/__init__.py | 29 ++-- python3/vimini/main.py | 113 +++++++++++++- python3/vimini/models.py | 59 +++++++- python3/vimini/util.py | 27 +++- tests/test_models.py | 23 ++- tests/test_vim_backend_ui.py | 162 +++++++++++++++++++++ 9 files changed, 447 insertions(+), 41 deletions(-) create mode 100644 tests/test_vim_backend_ui.py diff --git a/PROXY-API.md b/PROXY-API.md index 3d0b51a..d3fa02e 100644 --- a/PROXY-API.md +++ b/PROXY-API.md @@ -35,23 +35,7 @@ Implemented and Documented elsewhere ## 5. Runtime Backend Switching & Vim UI -### 5.1 Vimscript Commands & Options (`plugin/vimini.vim`) -- **Global Variable**: `g:vimini_backend` (the name of the active backend, e.g. `'Gemini'`, `'Custom 1'`). -- **Command**: `:ViminiBackend [backend_name]` - - **Without arguments**: Echoes the active backend and lists other available backends in parentheses. - - Example: `Gemini (available: Custom 1, Custom 2)` - - **With argument**: Validates that the requested backend exists in `~/.var/vimini/config.json`. If valid: - - Sets active backend in runtime and config. - - Updates `g:vimini_backend`. - - Updates `g:vimini_model` to the new backend's `default_model`. - - Sends an updated `setup` message to the agent server. - -### 5.2 Model List & Status Integration -- `:ViminiListModels`: - - For `genai` backends, models are categorized by supported actions (Multimodal, Embeddings, Image, etc.). - - For `openai` (ProxyAPI) backends, all models returned by `GET /v1/models` are listed under a single group: `[Available Models]`. - - Displays the active backend name in the buffer header. -- `:ViminiStatus`: Displays active backend name, protocol type, endpoint URL, and current model. +Implemented and Documented elsewhere --- @@ -67,8 +51,8 @@ Implemented and Documented elsewhere 2. **Phase 2: Abstraction & GenAI Adapter**: [COMPLETED] Refactor Google GenAI code into `GenAIBackend` matching `BaseClient` / `BaseChatSession` and adding `has_history()`. 3. **Phase 3: ProxyAPI Client**: [COMPLETED] Implement standard-library OpenAI client (`urllib.request`) supporting models listing, streaming completions, and tool calling. 4. **Phase 4: Agent Server & Session Integration**: [COMPLETED] Update `server.py`, `chat.py`, `code.py`, and `review.py` to use backend factory and honor `has_history()`. -5. **Phase 5: Vim Frontend & Commands**: Implement `:ViminiBackend` (with status echo and argument switching), single-group OpenAI model listing in `:ViminiListModels`, `:ViminiStatus`, and help documentation. -6. **Phase 6: Testing & Validation**: Add unit tests for config generation/parsing, backend switching, ProxyAPI mock requests, and single-group model listing. +5. **Phase 5: Vim Frontend & Commands**: [COMPLETED] Implement `:ViminiBackend` (with status echo and argument switching), single-group OpenAI model listing in `:ViminiListModels`, `:ViminiStatus`, and help documentation. +6. **Phase 6: Testing & Validation**: [COMPLETED] Add unit tests for config generation/parsing, backend switching, ProxyAPI mock requests, and single-group model listing. ## 8. Implementation strategy diff --git a/README.md b/README.md index 06692d8..f5695b2 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,31 @@ customizations. } ``` + You can configure multiple backends (Google Gemini via `"api": "genai"`, or OpenAI-compatible proxies and endpoints via `"api": "openai"`). + +12. **Active Backend (`g:vimini_backend`)**: + Specifies the active backend name matching a key in `~/.var/vimini/config.json`. The default is `'Gemini'`. + You can switch backends at runtime using the `:ViminiBackend` command. + ```vim + let g:vimini_backend = 'Gemini' + ``` + ## Usage +### `:ViminiBackend [backend_name]` + +Displays or switches the active model provider backend at runtime without restarting Vim: +* **Without arguments**: Echoes the active backend and lists other available backends in parentheses (e.g. `Gemini (available: Custom 1, Custom 2)`). +* **With argument**: Validates and switches to the specified backend, updating `g:vimini_backend` and `g:vimini_model` to that backend's default model, and sends an updated setup message to the background agent. Supports tab completion. + +```vim +" Check current backend +:ViminiBackend + +" Switch to an alternative backend +:ViminiBackend Custom 1 +``` + Vimini exposes several commands for interacting with the Gemini API: ### `:ViminiListModels` diff --git a/plugin/vimini.vim b/plugin/vimini.vim index 6525228..6a0b32d 100644 --- a/plugin/vimini.vim +++ b/plugin/vimini.vim @@ -12,6 +12,9 @@ let s:api_key_file = expand('~/.config/gemini.token') " Configuration: Model name let g:vimini_model = get(g:, 'vimini_model', 'gemini-3.6-flash') +" Configuration: Active Backend name +let g:vimini_backend = get(g:, 'vimini_backend', '') + " Configuration: How to split new windows ('vertical' or 'horizontal'). let g:vimini_split_method = get(g:, 'vimini_split_method', 'vertical') @@ -61,8 +64,13 @@ try: from vimini import main api_key_file = vim.eval('s:api_key_file') model = vim.eval('g:vimini_model') + backend = vim.eval('g:vimini_backend') log_file = vim.eval('g:vimini_log_file') if vim.eval('g:vimini_logging') == 'on' else None - main.initialize(api_key_file=api_key_file, model=model, logfile=log_file) + main.initialize(api_key_file=api_key_file, model=model, logfile=log_file, backend=backend if backend else None) + from vimini import util + if util._BACKEND: + vim.command(f"let g:vimini_backend = '{util._BACKEND}'") + vim.command(f"let g:vimini_model = '{util._MODEL}'") socket_path = main.start_agent() if socket_path: vim.command(f"let s:socket_path = '{socket_path}'") @@ -102,6 +110,26 @@ endfunction command! ViminiListModels call ViminiListModels() +" Expose a function to switch or display backend +function! ViminiBackend(...) + let l:backend = get(a:, 1, '') + py3 << EOF +try: + from vimini import main + backend_arg = vim.eval('l:backend') + main.backend_command(backend_arg if backend_arg else None) +except Exception as e: + error_message = str(e).replace("\n", " ").replace("\r", " ").replace("'", "''") + vim.command(f"echoerr '[Vimini] Error: {error_message}'") +EOF +endfunction + +function! ViminiBackendComplete(ArgLead, CmdLine, CursorPos) + return py3eval("main.get_available_backends(vim.eval('a:ArgLead'))") +endfunction + +command! -nargs=? -complete=customlist,ViminiBackendComplete ViminiBackend call ViminiBackend() + " Expose a function to Chat with Gemini function! ViminiChat() py3 << EOF diff --git a/python3/vimini/common/backends/__init__.py b/python3/vimini/common/backends/__init__.py index 7b5f99d..5a99f65 100644 --- a/python3/vimini/common/backends/__init__.py +++ b/python3/vimini/common/backends/__init__.py @@ -36,19 +36,26 @@ def get_backend( Factory function returning a BaseClient instance for the requested or active backend. Confines all backend selection logic within generic backend methods. """ - global_cfg = load_global_config(config_path=config_path) - backends = global_cfg.get("backends", {}) - - 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) + if isinstance(config, dict) and "backends" in config: + backends = config.get("backends", {}) + if backend_name is None: + backend_name = ( + config.get("active_backend") or config.get("backend") or "Gemini" + ) + else: + try: + global_cfg = load_global_config(config_path=config_path) + except Exception: + from vimini.common.config import create_default_global_config + + global_cfg = create_default_global_config() + backends = global_cfg.get("backends", {}) + 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) if backend_name not in backends: - # Check if config itself has backends dictionary - if isinstance(config, dict) and "backends" in config: - backends = config.get("backends", {}) if backend_name not in backends: available = list(backends.keys()) raise ValueError( diff --git a/python3/vimini/main.py b/python3/vimini/main.py index d1b4dad..f7d4cd6 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -31,12 +31,26 @@ ) -def initialize(api_key_file, model, logfile=None): +def initialize(api_key_file, model, logfile=None, backend=None): """ Initializes the plugin with the user's API key file path, model name, and optional logfile path. This function is called from the plugin's Vimscript entry point. """ + from vimini.common.config import load_global_config, get_active_backend + + try: + cfg = load_global_config(default_model=model, api_key_file=api_key_file) + if not backend: + backend = get_active_backend(cfg) + util._BACKEND = backend + active_b_cfg = cfg.get("backends", {}).get(backend, {}) + if active_b_cfg.get("api_key_file"): + api_key_file = os.path.expanduser(active_b_cfg.get("api_key_file")) + except Exception as e: + util.log_info(f"Failed to load global config during initialize: {e}") + util._BACKEND = backend or "Gemini" + util._API_KEY_FILE = api_key_file util._MODEL = model util._GENAI_CLIENT = None # Reset client if key/model changes. @@ -118,6 +132,99 @@ def send_setup(): return util.send_channel_request(req, silent=False) +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. + """ + from vimini.common.config import ( + load_global_config, + get_active_backend, + set_active_backend, + ) + + try: + cfg = load_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", {}) + + if not backend_name: + others = [b for b in backends.keys() if b != active] + if others: + util.display_message( + f"{active} (available: {', '.join(others)})", history=True + ) + else: + util.display_message( + f"{active} (no other backends configured)", history=True + ) + return + + backend_name_str = str(backend_name).strip() + if backend_name_str not in backends: + available = list(backends.keys()) + util.display_message( + f"Backend '{backend_name_str}' not found. Available: {available}", + error=True, + ) + return + + try: + set_active_backend(backend_name_str) + except Exception as e: + util.display_message(f"Error setting active backend: {e}", error=True) + return + + target_cfg = backends[backend_name_str] + default_model = target_cfg.get("default_model") + api_key_file = target_cfg.get("api_key_file") + + util._BACKEND = backend_name_str + try: + vim.command(f"let g:vimini_backend = '{backend_name_str}'") + except Exception: + pass + + if default_model: + util._MODEL = default_model + util._MODEL_NAME = None + try: + vim.command(f"let g:vimini_model = '{default_model}'") + except Exception: + pass + + if api_key_file: + util._API_KEY_FILE = os.path.expanduser(api_key_file) + + send_setup() + util.display_message( + f"Switched backend to {backend_name_str} (model: {util._MODEL})", + history=True, + ) + + +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 + + try: + cfg = load_global_config() + backends = list(cfg.get("backends", {}).keys()) + if arg_lead: + return [b for b in backends if b.lower().startswith(arg_lead.lower())] + return backends + except Exception: + return [] + + def start_agent(): """ Starts the agent server process, sends a setup request, and returns its Unix socket path. @@ -283,6 +390,10 @@ def help(command_name=None): "VIMINI HELP", "===========", "", + ":ViminiBackend [backend_name]", + " Switches or displays the active model backend (e.g. Gemini, ProxyAPI).", + " Without arguments, echoes active backend and other available backends.", + "", ":ViminiListModels", " Lists all available Gemini models in a new split window.", "", diff --git a/python3/vimini/models.py b/python3/vimini/models.py index 2b39b68..a0b10b6 100644 --- a/python3/vimini/models.py +++ b/python3/vimini/models.py @@ -141,24 +141,73 @@ def categorize_model(model): return "Other / Specialized" -def build_models_buffer(models, current_model): +def build_models_buffer(models, current_model, backend_name=None, api_type=None): """ Builds the buffer lines and line-to-model mapping grouped by category. 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 + + try: + cfg = load_global_config() + backend_name = getattr(util, "_BACKEND", None) or get_active_backend(cfg) + b_cfg = cfg.get("backends", {}).get(backend_name, {}) + api_type = b_cfg.get("api", "genai") + except Exception: + backend_name = getattr(util, "_BACKEND", "Gemini") + api_type = "genai" + buffer_lines = [ - "| Vimini Available Models", + f"| Vimini Available Models (Backend: {backend_name})", "|----------------------------------------------------------------------", "| : switch model | q: close", - "| Note: Only 'Multimodal & Text Generation' models are currently supported.", - "", ] + if api_type == "genai": + buffer_lines.append( + "| Note: Only 'Multimodal & Text Generation' models are currently supported.", + ) + buffer_lines.append("") + line_map = {} current_model_line = None if current_model and current_model.startswith("models/"): current_model = current_model[7:] + if api_type == "openai": + cat = "Available Models" + buffer_lines.append(f"[{cat}]") + line_map[len(buffer_lines)] = None + + for m in models: + if isinstance(m, str): + name = m + display = m + else: + name = m.get("name", "") + display = m.get("display_name", name) + + if name.startswith("models/"): + name = name[7:] + + is_current = name == current_model + prefix = " * " if is_current else " " + buffer_lines.append(f"{prefix}{display}") + line_num = len(buffer_lines) + line_map[line_num] = { + "model": m, + "name": name, + "display": display, + "category": cat, + } + if is_current and current_model_line is None: + current_model_line = line_num + + buffer_lines.append("") + line_map[len(buffer_lines)] = None + return buffer_lines, line_map, current_model_line + from collections import defaultdict grouped = defaultdict(list) @@ -278,7 +327,7 @@ def select_model(): return cat = entry.get("category") - if cat != SUPPORTED_CATEGORY: + if cat != SUPPORTED_CATEGORY and cat != "Available Models": util.display_message(f"Category '{cat}' is unsupported", error=True) return diff --git a/python3/vimini/util.py b/python3/vimini/util.py index e1c02a5..e72e34b 100644 --- a/python3/vimini/util.py +++ b/python3/vimini/util.py @@ -10,6 +10,7 @@ # Module-level variables to store the API key file, model name, and client instance. _API_KEY_FILE = None +_BACKEND = None _MODEL = None _MODEL_NAME = None _GENAI_CLIENT = None # Global, lazily-initialized client. @@ -844,8 +845,32 @@ def update_status_buffer(): # so it updates when we switch back to the tab with status window. return - lines = [f"{_STATUS_BUFFER_NAME}", "===========================", ""] + from vimini.common.config import load_global_config, get_active_backend + try: + cfg = load_global_config() + backend_name = _BACKEND or get_active_backend(cfg) + b_cfg = cfg.get("backends", {}).get(backend_name, {}) + api_type = b_cfg.get("api", "genai") + endpoint = b_cfg.get( + "base_url", + "https://generativelanguage.googleapis.com" + if api_type == "genai" + else "N/A", + ) + except Exception: + backend_name = _BACKEND or "Gemini" + api_type = "genai" + endpoint = "N/A" + + lines = [ + f"{_STATUS_BUFFER_NAME}", + "===========================", + f"Backend: {backend_name} ({api_type})", + f"Endpoint: {endpoint}", + f"Model: {_MODEL or 'default'}", + "", + ] if not _ACTIVE_JOBS: lines.append("No active jobs.") else: diff --git a/tests/test_models.py b/tests/test_models.py index 9e42b13..6e5c5f7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,6 @@ import sys import os +import pytest from unittest.mock import MagicMock, patch # Ensure python3 root is in sys.path @@ -17,6 +18,22 @@ import vimini.models as models_module from vimini import util +GENAI_GLOBAL_CONFIG = { + "active_backend": "Gemini", + "backends": { + "Gemini": {"api": "genai"}, + }, +} + + +@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 + ): + yield + def test_categorize_model(): # Multimodal & Text Generation @@ -163,7 +180,7 @@ def test_categorize_model(): ) -def test_build_models_buffer(): +def test_build_models_buffer(genai_global_config): models = [ {"name": "gemini-2.5-flash", "display_name": "Gemini 2.5 Flash"}, {"name": "gemini-2.5-pro", "display_name": "Gemini 2.5 Pro"}, @@ -189,7 +206,7 @@ def test_build_models_buffer(): assert pro_line == " Gemini 2.5 Pro" -def test_select_supported_model(): +def test_select_supported_model(genai_global_config): models = [ {"name": "gemini-2.5-flash", "display_name": "Gemini 2.5 Flash"}, {"name": "imagen-3.0", "display_name": "Imagen 3"}, @@ -221,7 +238,7 @@ def test_select_supported_model(): mock_display.assert_called_once_with("Switched model to gemini-2.5-flash") -def test_select_unsupported_model_emits_error(): +def test_select_unsupported_model_emits_error(genai_global_config): models = [ {"name": "gemini-2.5-flash", "display_name": "Gemini 2.5 Flash"}, {"name": "imagen-3.0", "display_name": "Imagen 3"}, diff --git a/tests/test_vim_backend_ui.py b/tests/test_vim_backend_ui.py new file mode 100644 index 0000000..a51a40e --- /dev/null +++ b/tests/test_vim_backend_ui.py @@ -0,0 +1,162 @@ +import sys +import os +import json +import pytest +from unittest.mock import MagicMock, patch + +sys.path.insert( + 0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "python3")) +) + +from vimini import main, util +from vimini.models import build_models_buffer + + +def test_backend_command_without_arguments(): + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": {"api": "genai"}, + "Custom 1": {"api": "openai"}, + "Custom 2": {"api": "openai"}, + }, + } + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.util.display_message") as mock_display, + ): + main.backend_command(None) + mock_display.assert_called_once() + msg = mock_display.call_args[0][0] + assert "Gemini (available: Custom 1, Custom 2)" in msg + + +def test_backend_command_without_arguments_no_others(): + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": {"api": "genai"}, + }, + } + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.util.display_message") as mock_display, + ): + main.backend_command("") + mock_display.assert_called_once() + msg = mock_display.call_args[0][0] + assert "Gemini (no other backends configured)" in msg + + +def test_backend_command_switch_success(): + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": {"api": "genai", "default_model": "gemini-3.8-flash"}, + "Custom 1": { + "api": "openai", + "default_model": "gpt-4o", + "api_key_file": "~/.config/proxy.token", + }, + }, + } + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.common.config.set_active_backend") as mock_set_active, + 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") + assert util._BACKEND == "Custom 1" + assert util._MODEL == "gpt-4o" + assert util._API_KEY_FILE == os.path.expanduser("~/.config/proxy.token") + mock_send_setup.assert_called_once() + mock_display.assert_called_once() + assert ( + "Switched backend to Custom 1 (model: gpt-4o)" + in mock_display.call_args[0][0] + ) + + +def test_backend_command_switch_invalid(): + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": {"api": "genai"}, + }, + } + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.util.display_message") as mock_display, + ): + main.backend_command("NonExistent") + mock_display.assert_called_once() + assert mock_display.call_args.kwargs.get("error") is True + assert "not found" in mock_display.call_args[0][0] + + +def test_get_available_backends(): + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": {"api": "genai"}, + "Custom 1": {"api": "openai"}, + "Custom 2": {"api": "openai"}, + }, + } + + with patch("vimini.common.config.load_global_config", return_value=config): + 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"] + + +def test_build_models_buffer_openai_single_group(): + models = [ + {"name": "gpt-4o", "display_name": "GPT-4o"}, + {"name": "o3-mini", "display_name": "o3-mini"}, + ] + + buffer_lines, line_map, current_line = build_models_buffer( + models, current_model="gpt-4o", backend_name="Custom 1", api_type="openai" + ) + + header = buffer_lines[0] + assert "Backend: Custom 1" in header + assert "[Available Models]" in buffer_lines + assert " * GPT-4o" in buffer_lines + assert " o3-mini" in buffer_lines + # No restriction note in openai mode + assert not any( + "Only 'Multimodal & Text Generation'" in line for line in buffer_lines + ) + + +def test_build_models_buffer_genai_categories(): + models = [ + { + "name": "gemini-2.5-flash", + "display_name": "Gemini 2.5 Flash", + "supported_actions": ["generateContent"], + } + ] + + buffer_lines, line_map, current_line = build_models_buffer( + models, + current_model="gemini-2.5-flash", + backend_name="Gemini", + api_type="genai", + ) + + header = buffer_lines[0] + assert "Backend: Gemini" in header + assert "[Multimodal & Text Generation]" in buffer_lines + assert any("Only 'Multimodal & Text Generation'" in line for line in buffer_lines) From 65172a34cdf6099b05d7300a780748207a1174e7 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 22:10:41 -0400 Subject: [PATCH 05/19] Preserve provider metadata in proxy tool calls Retain opaque provider-specific metadata (such as Gemini's thought_signature) when streaming tool calls in ProxyBackend. Certain OpenAI-compatible providers reject follow-up requests if assistant tool-call signatures are not echoed back verbatim. The internal message history now preserves extra metadata while keeping the public ToolCall abstraction clean. Also remove the obsolete PROXY-API.md planning document and add regression tests. Assisted-by: Gemini:gemini-3.8-flash Signed-off-by: Simo Sorce --- PROXY-API.md | 63 ------------ .../vimini/common/backends/proxy_backend.py | 96 ++++++++++++++----- tests/test_proxy_backend.py | 69 +++++++++++++ 3 files changed, 139 insertions(+), 89 deletions(-) delete mode 100644 PROXY-API.md diff --git a/PROXY-API.md b/PROXY-API.md deleted file mode 100644 index d3fa02e..0000000 --- a/PROXY-API.md +++ /dev/null @@ -1,63 +0,0 @@ -# ProxyAPI Integration Plan - -This document outlines the architectural plan for adding support for OpenAI-compatible APIs (**ProxyAPI**) alongside the default Google Gemini (**GenAI**) backend in Vimini. - ---- - -## 1. Overview & Goals - -- **Dual Backend Architecture**: Support both the default Google Gemini backend (`genai`) and an OpenAI-compatible proxy or model provider backend (`ProxyAPI`). -- **De Facto Standard Compatibility**: Enable compatibility with standard OpenAI API endpoints (such as OpenAI, OpenRouter, LiteLLM, Ollama, vLLM, and LocalAI). Anthropic protocol support is left as a future extension. -- **Persistent Configuration**: Store endpoints, API key file paths, default models, and custom headers in `~/.var/vimini/config.json`. -- **Automatic Default Generation**: If `~/.var/vimini/config.json` does not exist, automatically generate a v1 configuration file based on current settings and behavior. -- **Dynamic Runtime Switching**: Allow switching between backends at runtime via `:ViminiBackend` without restarting Vim or the background agent server. -- **Zero Additional Dependencies**: Implement the ProxyAPI HTTP and streaming communication using Python's standard library (`urllib.request` / `http.client`, `json`, `ssl`), adhering to Vimini's guideline of avoiding third-party dependencies outside `google-genai`. - ---- - -## 2. Configuration & Credential Management - -Implemented and Documented elsewhere - ---- - -## 3. Backend Abstraction Layer - -Implemented and Documented elsewhere - ---- - -## 4. ProxyAPI Implementation Details - -Implemented and Documented elsewhere - ---- - -## 5. Runtime Backend Switching & Vim UI - -Implemented and Documented elsewhere - ---- - -## 6. Agent Server & IPC Protocol Updates - -Implemented and Documented elsewhere - ---- - -## 7. Implementation Roadmap - -1. **Phase 1: Config Management**: [COMPLETED] Implement configuration loader and automatic v1 default generator for `~/.var/vimini/config.json`. -2. **Phase 2: Abstraction & GenAI Adapter**: [COMPLETED] Refactor Google GenAI code into `GenAIBackend` matching `BaseClient` / `BaseChatSession` and adding `has_history()`. -3. **Phase 3: ProxyAPI Client**: [COMPLETED] Implement standard-library OpenAI client (`urllib.request`) supporting models listing, streaming completions, and tool calling. -4. **Phase 4: Agent Server & Session Integration**: [COMPLETED] Update `server.py`, `chat.py`, `code.py`, and `review.py` to use backend factory and honor `has_history()`. -5. **Phase 5: Vim Frontend & Commands**: [COMPLETED] Implement `:ViminiBackend` (with status echo and argument switching), single-group OpenAI model listing in `:ViminiListModels`, `:ViminiStatus`, and help documentation. -6. **Phase 6: Testing & Validation**: [COMPLETED] Add unit tests for config generation/parsing, backend switching, ProxyAPI mock requests, and single-group model listing. - -## 8. Implementation strategy - -As implementation progresses, phases are marked as complete, and information is -moved to the README file in concise form and only as necessary, information that -is no longer relevant because a phase or feature is already implemented is -simply removed and the section content is replaced with the standard text -"Implemented and Documented elsewhere" diff --git a/python3/vimini/common/backends/proxy_backend.py b/python3/vimini/common/backends/proxy_backend.py index cf0960f..44c992b 100644 --- a/python3/vimini/common/backends/proxy_backend.py +++ b/python3/vimini/common/backends/proxy_backend.py @@ -147,6 +147,55 @@ def has_history(self) -> bool: def get_history(self) -> List[Any]: return [] + @staticmethod + def _merge_streamed_tool_call( + target: Dict[str, Any], source: Dict[str, Any] + ) -> None: + """ + Merge a streamed OpenAI-compatible tool_call delta. + + Some providers expose OpenAI-compatible APIs while still requiring + opaque provider metadata to be replayed verbatim on the assistant + tool-call message in the next request. Google Gemini is one such + provider: when thinking/tool calls are used, it may attach a + thought_signature to function-call parts, and later rejects the + conversation if that signature is dropped. + + Keep this strictly inside the ProxyAPI backend: the public Vimini + abstraction still exposes only ToolCall(name, args, call_id), while the + backend preserves unknown protocol fields in its own message history. + """ + if source.get("id"): + target["id"] = source.get("id") + if source.get("type"): + target["type"] = source.get("type") + + source_function = source.get("function") or {} + target_function = target.setdefault("function", {}) + if source_function.get("name"): + target_function["name"] = target_function.get( + "name", "" + ) + source_function.get("name", "") + if source_function.get("arguments"): + target_function["arguments"] = target_function.get( + "arguments", "" + ) + source_function.get("arguments", "") + + # Preserve provider-specific function metadata, for example Gemini's + # opaque thought_signature, without exposing it through ToolCall. + for key, value in source_function.items(): + if key in ("name", "arguments"): + continue + if value is not None: + target_function[key] = value + + # Preserve provider-specific top-level metadata as well. + for key, value in source.items(): + if key in ("index", "id", "type", "function"): + continue + if value is not None: + target[key] = value + def send_message_stream( self, prompt_or_tool_responses: Any ) -> Iterator[StreamChunk]: @@ -211,20 +260,10 @@ def send_message_stream( if idx not in tool_call_chunks: tool_call_chunks[idx] = { "id": tc.get("id"), - "name": tc.get("function", {}).get("name", ""), - "arguments": tc.get("function", {}).get("arguments", ""), + "type": tc.get("type") or "function", + "function": {"name": "", "arguments": ""}, } - else: - if tc.get("id"): - tool_call_chunks[idx]["id"] = tc.get("id") - if tc.get("function", {}).get("name"): - tool_call_chunks[idx]["name"] += tc.get("function", {}).get( - "name" - ) - if tc.get("function", {}).get("arguments"): - tool_call_chunks[idx]["arguments"] += tc.get( - "function", {} - ).get("arguments") + self._merge_streamed_tool_call(tool_call_chunks[idx], tc) if text_chunk or thought_chunk: yield StreamChunk(text=text_chunk, thought=thought_chunk) @@ -235,14 +274,19 @@ def send_message_stream( for idx in sorted(tool_call_chunks.keys()): tc_info = tool_call_chunks[idx] call_id = tc_info.get("id") or f"call_{idx}" - args_str = tc_info.get("arguments", "") + fn_info = tc_info.get("function", {}) + args_str = fn_info.get("arguments", "") try: args_dict = json.loads(args_str) if args_str else {} except Exception: args_dict = {"raw": args_str} completed_tool_calls.append( - ToolCall(name=tc_info["name"], args=args_dict, call_id=call_id) + ToolCall( + name=fn_info.get("name", ""), + args=args_dict, + call_id=call_id, + ) ) yield StreamChunk(tool_calls=completed_tool_calls) @@ -252,17 +296,17 @@ def send_message_stream( if accumulated_text: asst_msg["content"] = accumulated_text if completed_tool_calls: - asst_msg["tool_calls"] = [ - { - "id": tc.call_id or f"call_{i}", - "type": "function", - "function": { - "name": tc.name, - "arguments": json.dumps(tc.args), - }, - } - for i, tc in enumerate(completed_tool_calls) - ] + asst_msg["tool_calls"] = [] + for i, tc in enumerate(completed_tool_calls): + raw_tool_call = dict(tool_call_chunks.get(i, {})) + raw_function = dict(raw_tool_call.get("function", {})) + raw_function["name"] = tc.name + raw_function["arguments"] = json.dumps(tc.args) + + raw_tool_call["id"] = tc.call_id or f"call_{i}" + raw_tool_call["type"] = raw_tool_call.get("type") or "function" + raw_tool_call["function"] = raw_function + asst_msg["tool_calls"].append(raw_tool_call) if accumulated_text or completed_tool_calls: self.messages.append(asst_msg) diff --git a/tests/test_proxy_backend.py b/tests/test_proxy_backend.py index c687923..e31d0f2 100644 --- a/tests/test_proxy_backend.py +++ b/tests/test_proxy_backend.py @@ -292,6 +292,75 @@ def test_proxy_chat_session_streaming_and_tools(): assert messages[3]["content"] == "file content here" +def test_proxy_chat_session_preserves_provider_tool_metadata(): + backend = ProxyBackend() + session = backend.create_chat( + model="gemini-via-openai-proxy", + tools=[{"name": "git_grep", "parameters": {}}], + ) + + first_turn = [ + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_sig", + "type": "function", + "function": { + "name": "git_grep", + "arguments": '{"pattern":"PROXY-API.md"}', + "thought_signature": "opaque-gemini-signature", + }, + "provider_extra": "keep-me", + } + ] + } + } + ] + } + ] + first_resp = io.BytesIO( + b"".join( + [f"data: {json.dumps(chunk)}\n\n".encode("utf-8") for chunk in first_turn] + + [b"data: [DONE]\n\n"] + ) + ) + + with patch.object(backend, "_make_request", return_value=first_resp): + chunks = list(session.send_message_stream("Find the docs")) + + assert len(chunks) == 1 + assert chunks[0].tool_calls == [ + ToolCall( + name="git_grep", + args={"pattern": "PROXY-API.md"}, + call_id="call_sig", + ) + ] + + second_resp = io.BytesIO( + b'data: {"choices": [{"delta": {"content": "done"}}]}\n\ndata: [DONE]\n\n' + ) + tool_response = ToolResponse( + name="git_grep", + response={"result": "matches"}, + call_id="call_sig", + ) + with patch.object(backend, "_make_request", return_value=second_resp) as mock_req: + list(session.send_message_stream([tool_response])) + + messages = mock_req.call_args[1]["data"]["messages"] + assistant_tool_call = messages[1]["tool_calls"][0] + assert assistant_tool_call["provider_extra"] == "keep-me" + assert ( + assistant_tool_call["function"]["thought_signature"] + == "opaque-gemini-signature" + ) + + def test_get_backend_factory_resolves_proxy_backend(): config = { "active_backend": "Custom 1", From 0df389e755068b3630804513e35c3f90bffa7157 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 22:11:47 -0400 Subject: [PATCH 06/19] Add global configuration editor Add :ViminiGlobalConfig to manage global settings and backend providers from an interactive Vim split. The editor supports editing, adding, deleting, resetting, validating, and saving backends, then applies saved backend changes to the current runtime. Refactor backend switching through a shared apply_backend_config helper and document the new command. Add tests for rendering the editor and applying backend config. Assisted-by: gpt-5.5 Signed-off-by: Simo Sorce --- README.md | 18 ++ plugin/vimini.vim | 14 + python3/vimini/config.py | 456 +++++++++++++++++++++++++++++++++ python3/vimini/main.py | 65 +++-- tests/test_global_config_ui.py | 103 ++++++++ 5 files changed, 630 insertions(+), 26 deletions(-) create mode 100644 tests/test_global_config_ui.py diff --git a/README.md b/README.md index f5695b2..7dbef38 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,24 @@ customizations. You can configure multiple backends (Google Gemini via `"api": "genai"`, or OpenAI-compatible proxies and endpoints via `"api": "openai"`). + You can also edit this file interactively from Vim with `:ViminiGlobalConfig`. + +### `:ViminiGlobalConfig` + +Opens an interactive configuration editor in a new split window to configure global Vimini settings and model provider backends stored in `~/.var/vimini/config.json`. + +**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. +* **`a`**: Adds a new backend and prompts for API type, default model, API key file, and base URL for OpenAI-compatible backends. +* **`d`**: Clears the selected backend field or deletes the selected backend after confirmation. The last backend cannot be deleted. +* **`r`**: Resets all global configuration options to their last saved state. +* **`q`**: Closes the configuration window. If modifications were made, a confirmation prompt asks whether to save changes permanently to `~/.var/vimini/config.json`. + +```vim +:ViminiGlobalConfig +``` + 12. **Active Backend (`g:vimini_backend`)**: Specifies the active backend name matching a key in `~/.var/vimini/config.json`. The default is `'Gemini'`. You can switch backends at runtime using the `:ViminiBackend` command. diff --git a/plugin/vimini.vim b/plugin/vimini.vim index 6a0b32d..d333fd2 100644 --- a/plugin/vimini.vim +++ b/plugin/vimini.vim @@ -491,6 +491,20 @@ endfunction command! -nargs=0 ViminiConfig call ViminiConfig() +" Expose a function to manage global/backend configuration +function! ViminiGlobalConfig() + py3 << EOF +try: + from vimini import main + main.global_config_command() +except Exception as e: + error_message = str(e).replace("\n", " ").replace("\r", " ").replace("'", "''") + vim.command(f"echoerr '[Vimini] Error: {error_message}'") +EOF +endfunction + +command! -nargs=0 ViminiGlobalConfig call ViminiGlobalConfig() + " Expose a function for autocompletion. " This calls the non-blocking python function that handles the async request. function! ViminiAutocomplete() diff --git a/python3/vimini/config.py b/python3/vimini/config.py index e3e21aa..069c1dc 100644 --- a/python3/vimini/config.py +++ b/python3/vimini/config.py @@ -2,6 +2,7 @@ import os import json import tempfile +import copy from . import util from vimini.common.util import ( PROJECT_CONFIG_SCHEMA, @@ -11,6 +12,12 @@ get_project_data_file_path, get_project_name, ) +from vimini.common.config import ( + get_global_config_path, + load_global_config, + save_global_config, + validate_global_config, +) CONFIG_JSON_HELP_HEADER = """# Vimini Project Command Configuration # Lines starting with '#' are comments and will be ignored when saving. @@ -73,6 +80,11 @@ _VIMINI_CONFIG_PROJECT_NAME = None _JSON_EDITOR_STATE = {} +_VIMINI_PENDING_GLOBAL_CONFIG = None +_VIMINI_ORIGINAL_GLOBAL_CONFIG = None +_VIMINI_GLOBAL_CONFIG_PATH = None +_GLOBAL_CONFIG_LINE_MAP = {} + def _to_str(val): if isinstance(val, bytes): @@ -80,6 +92,376 @@ def _to_str(val): return str(val) if val is not None else "" +def _vim_string(value): + return json.dumps(_to_str(value)) + + +def _add_mapped_line(lines, line_map, line, target=None): + lines.append(line) + if target is not None: + line_map[len(lines)] = target + + +def _ordered_backend_fields(backend_cfg): + fields = [] + for key in ("api", "api_key_file", "default_model"): + if key in backend_cfg or key == "api": + fields.append(key) + if backend_cfg.get("api") == "openai" or "base_url" in backend_cfg: + fields.append("base_url") + for key in backend_cfg.keys(): + if key not in fields: + fields.append(key) + return fields + + +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", "") + backends = config_data.get("backends", {}) + lines = [] + line_map = {} + + _add_mapped_line(lines, line_map, "| Vimini Global Configuration") + _add_mapped_line( + lines, + line_map, + "|----------------------------------------------------------------------", + ) + _add_mapped_line( + lines, + line_map, + "| /e: edit value | a: add backend | d: delete/clear | r: reset | q: close", + ) + _add_mapped_line(lines, line_map, f"| File: {config_path}") + _add_mapped_line(lines, line_map, "") + _add_mapped_line(lines, line_map, "> GLOBAL OPTIONS") + _add_mapped_line( + lines, + line_map, + f" active_backend = {active_backend or '(not set)'}", + ("global", "active_backend"), + ) + _add_mapped_line(lines, line_map, " # Currently selected model backend") + _add_mapped_line(lines, line_map, "") + _add_mapped_line(lines, line_map, "> BACKENDS") + + if not isinstance(backends, dict) or not backends: + _add_mapped_line(lines, line_map, " (no backends configured)") + else: + for backend_name, backend_cfg in backends.items(): + if not isinstance(backend_cfg, dict): + backend_cfg = {} + _add_mapped_line(lines, line_map, "") + marker = "*" if backend_name == active_backend else " " + _add_mapped_line( + lines, + line_map, + f"{marker} {backend_name}", + ("backend", backend_name), + ) + for field in _ordered_backend_fields(backend_cfg): + value = backend_cfg.get(field) + value_str = ( + str(value) if value is not None and value != "" else "(not set)" + ) + _add_mapped_line( + lines, + line_map, + f" {field} = {value_str}", + ("backend_field", backend_name, field), + ) + + return lines, line_map + + +def _set_global_config_syntax(): + vim.command("syntax match ViminiConfigKey '^\\s*[a-zA-Z0-9_-]\\+\\ze\\s*='") + vim.command("syntax match ViminiConfigValue '=\\s*\\zs.*$'") + vim.command("syntax match ViminiConfigComment '^\\s*#.*$'") + vim.command("syntax match ViminiConfigHeader '^|.*'") + vim.command("syntax match ViminiConfigSection '^>.*'") + vim.command("syntax match ViminiConfigBackend '^\\s*[ *] .*$'") + vim.command("highlight default link ViminiConfigKey Identifier") + vim.command("highlight default link ViminiConfigValue String") + vim.command("highlight default link ViminiConfigComment Comment") + vim.command("highlight default link ViminiConfigHeader Comment") + vim.command("highlight default link ViminiConfigSection Title") + vim.command("highlight default link ViminiConfigBackend Type") + + +def _get_global_target_at_cursor(): + try: + line_num, _col = vim.current.window.cursor + return _GLOBAL_CONFIG_LINE_MAP.get(line_num) + except Exception: + return None + + +def _refresh_global_config_buffer(win=None, line_num=None, col=None, buf_nr=None): + global _GLOBAL_CONFIG_LINE_MAP + target_buf = None + if buf_nr: + try: + target_buf_nr = int(buf_nr) + for b in vim.buffers: + if b.number == target_buf_nr: + target_buf = b + break + except Exception: + pass + + if target_buf is None and win is not None: + try: + target_buf = win.buffer + except Exception: + pass + + if target_buf is None: + for b in vim.buffers: + if b.name and os.path.basename(b.name) == "ViminiGlobalConfig": + target_buf = b + break + + if target_buf is None: + target_buf = vim.current.buffer + + lines, line_map = _draw_global_config_listing( + _VIMINI_PENDING_GLOBAL_CONFIG or {}, _VIMINI_GLOBAL_CONFIG_PATH + ) + _GLOBAL_CONFIG_LINE_MAP = line_map + + target_buf.options["readonly"] = False + target_buf.options["modifiable"] = True + try: + target_buf[:] = lines + finally: + target_buf.options["modifiable"] = False + target_buf.options["readonly"] = True + + for w in vim.windows: + try: + if w.buffer.number == target_buf.number: + w.options["wrap"] = True + if line_num is not None and col is not None: + w.cursor = (min(line_num, len(lines)), col) + except Exception: + pass + vim.command("redraw") + + +def global_config_command(): + """Shows a guided editor for ~/.var/vimini/config.json.""" + global _VIMINI_PENDING_GLOBAL_CONFIG, _VIMINI_ORIGINAL_GLOBAL_CONFIG + global _VIMINI_GLOBAL_CONFIG_PATH, _GLOBAL_CONFIG_LINE_MAP + util.log_info("global_config_command()") + try: + config_path = get_global_config_path() + data = load_global_config(config_path=config_path) + _VIMINI_ORIGINAL_GLOBAL_CONFIG = copy.deepcopy(data) + _VIMINI_PENDING_GLOBAL_CONFIG = copy.deepcopy(data) + _VIMINI_GLOBAL_CONFIG_PATH = config_path + lines, line_map = _draw_global_config_listing(data, config_path) + _GLOBAL_CONFIG_LINE_MAP = line_map + + util.new_split() + vim.command("file ViminiGlobalConfig") + buf = vim.current.buffer + buf[:] = lines + buf.options["buftype"] = "nofile" + buf.options["swapfile"] = False + buf.options["modifiable"] = False + buf.options["readonly"] = True + try: + vim.current.window.options["wrap"] = True + except Exception: + pass + buf.vars["vimini_global_config_path"] = config_path + + _set_global_config_syntax() + vim.command( + "nnoremap :py3 from vimini.config import edit_global_config_option; edit_global_config_option()" + ) + vim.command( + "nnoremap e :py3 from vimini.config import edit_global_config_option; edit_global_config_option()" + ) + vim.command( + "nnoremap a :py3 from vimini.config import add_global_backend; add_global_backend()" + ) + vim.command( + "nnoremap d :py3 from vimini.config import delete_or_clear_global_config_option; delete_or_clear_global_config_option()" + ) + vim.command( + "nnoremap r :py3 from vimini.config import reset_global_config_options; reset_global_config_options()" + ) + vim.command("nnoremap q :q") + vim.command( + "autocmd BufUnload :py3 from vimini.config import confirm_global_config; confirm_global_config()" + ) + vim.current.window.cursor = (7, 2) + except Exception as e: + util.display_message(f"Error opening global configuration: {e}", error=True) + + +def edit_global_config_option(): + global _VIMINI_PENDING_GLOBAL_CONFIG + try: + target = _get_global_target_at_cursor() + if _VIMINI_PENDING_GLOBAL_CONFIG is None or not target: + util.display_message("No editable global configuration option selected.") + return + win = vim.current.window + line_num, col = win.cursor + backends = _VIMINI_PENDING_GLOBAL_CONFIG.setdefault("backends", {}) + kind = target[0] + + if kind == "global" and target[1] == "active_backend": + names = list(backends.keys()) + current = _VIMINI_PENDING_GLOBAL_CONFIG.get("active_backend", "") + prompt = f"Active 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 + _refresh_global_config_buffer(win, line_num, col) + util.display_message(f"Set active backend to '{new_val}'.") + elif new_val: + util.display_message( + f"Backend '{new_val}' is not configured.", error=True + ) + return + + if kind == "backend_field": + backend_name, field = target[1], target[2] + backend_cfg = backends.setdefault(backend_name, {}) + current = backend_cfg.get(field, "") + prompt = f"{backend_name}.{field}: " + new_val = _to_str( + vim.eval(f"input({_vim_string(prompt)}, {_vim_string(current)})") + ).strip() + if not new_val: + if field == "api": + util.display_message("Backend api cannot be empty.", error=True) + return + backend_cfg.pop(field, None) + else: + backend_cfg[field] = new_val + _refresh_global_config_buffer(win, line_num, col) + util.display_message(f"Updated '{backend_name}.{field}'.") + return + + if kind == "backend": + backend_name = target[1] + new_name = _to_str( + vim.eval(f"input('Backend name: ', {_vim_string(backend_name)})") + ).strip() + if new_name and new_name != backend_name: + if new_name in backends: + util.display_message( + f"Backend '{new_name}' already exists.", error=True + ) + 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 + _refresh_global_config_buffer(win, line_num, col) + util.display_message( + f"Renamed backend '{backend_name}' to '{new_name}'." + ) + except Exception as e: + util.display_message(f"Error editing global configuration: {e}", error=True) + + +def add_global_backend(): + global _VIMINI_PENDING_GLOBAL_CONFIG + try: + if _VIMINI_PENDING_GLOBAL_CONFIG is None: + util.display_message( + "Global configuration editor is not active.", error=True + ) + return + backends = _VIMINI_PENDING_GLOBAL_CONFIG.setdefault("backends", {}) + name = _to_str(vim.eval("input('Backend name: ')")).strip() + if not name: + return + if name in backends: + util.display_message(f"Backend '{name}' already exists.", error=True) + return + api = ( + _to_str(vim.eval("input('API type [genai/openai]: ', 'openai')")).strip() + or "openai" + ) + default_model = _to_str(vim.eval("input('Default model: ')")).strip() + api_key_file = _to_str(vim.eval("input('API key file: ')")).strip() + backend_cfg = {"api": api} + if api_key_file: + backend_cfg["api_key_file"] = api_key_file + if default_model: + backend_cfg["default_model"] = default_model + if api == "openai": + base_url = _to_str(vim.eval("input('Base URL: ')")).strip() + 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 + _refresh_global_config_buffer(vim.current.window, *vim.current.window.cursor) + util.display_message(f"Added backend '{name}'.") + except Exception as e: + util.display_message(f"Error adding backend: {e}", error=True) + + +def delete_or_clear_global_config_option(): + global _VIMINI_PENDING_GLOBAL_CONFIG + try: + target = _get_global_target_at_cursor() + if _VIMINI_PENDING_GLOBAL_CONFIG is None or not target: + util.display_message("No global configuration option selected.") + return + win = vim.current.window + line_num, col = win.cursor + backends = _VIMINI_PENDING_GLOBAL_CONFIG.setdefault("backends", {}) + kind = target[0] + if kind == "backend_field": + backend_name, field = target[1], target[2] + if field == "api": + util.display_message("Backend api cannot be cleared.", error=True) + return + backends.setdefault(backend_name, {}).pop(field, None) + _refresh_global_config_buffer(win, line_num, col) + util.display_message(f"Cleared '{backend_name}.{field}'.") + elif kind == "backend": + backend_name = target[1] + if len(backends) <= 1: + util.display_message( + "Cannot delete the last configured backend.", error=True + ) + return + confirmed = ( + int( + vim.eval( + f"confirm({_vim_string(f'Delete backend {backend_name}?')}, '&Yes\\n&No', 2)" + ) + ) + == 1 + ) + if confirmed: + backends.pop(backend_name, None) + if _VIMINI_PENDING_GLOBAL_CONFIG.get("active_backend") == backend_name: + _VIMINI_PENDING_GLOBAL_CONFIG["active_backend"] = next( + iter(backends.keys()) + ) + _refresh_global_config_buffer(win, line_num, col) + util.display_message(f"Deleted backend '{backend_name}'.") + else: + util.display_message( + "active_backend cannot be cleared; select another backend instead." + ) + except Exception as e: + util.display_message(f"Error updating global configuration: {e}", error=True) + + def _get_key_for_line(line): if not line: return None @@ -879,6 +1261,80 @@ def reset_config_options(): util.display_message(f"Error resetting configuration options: {e}", error=True) +def reset_global_config_options(): + global _VIMINI_PENDING_GLOBAL_CONFIG + try: + if _VIMINI_ORIGINAL_GLOBAL_CONFIG is None: + return + _VIMINI_PENDING_GLOBAL_CONFIG = copy.deepcopy(_VIMINI_ORIGINAL_GLOBAL_CONFIG) + win = vim.current.window + line_num, col = win.cursor + _refresh_global_config_buffer(win, line_num, col) + util.display_message("Reset global configuration to last saved state.") + except Exception as e: + util.display_message(f"Error resetting global configuration: {e}", error=True) + + +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) + except Exception as e: + util.log_info(f"Failed to apply saved global config at runtime: {e}") + + +def confirm_global_config(): + global _VIMINI_PENDING_GLOBAL_CONFIG, _VIMINI_ORIGINAL_GLOBAL_CONFIG + global _VIMINI_GLOBAL_CONFIG_PATH, _GLOBAL_CONFIG_LINE_MAP + try: + if ( + _VIMINI_PENDING_GLOBAL_CONFIG is None + or _VIMINI_ORIGINAL_GLOBAL_CONFIG is None + ): + return + if _VIMINI_PENDING_GLOBAL_CONFIG == _VIMINI_ORIGINAL_GLOBAL_CONFIG: + return + + try: + validate_global_config(_VIMINI_PENDING_GLOBAL_CONFIG) + except Exception as e: + util.display_message(f"Global configuration is invalid: {e}", error=True) + return + + confirmed = ( + int( + vim.eval( + "confirm('Save global configuration changes?', '&Yes\\n&No', 2)" + ) + ) + == 1 + ) + if confirmed: + if save_global_config( + _VIMINI_PENDING_GLOBAL_CONFIG, config_path=_VIMINI_GLOBAL_CONFIG_PATH + ): + _apply_saved_global_config_runtime(_VIMINI_PENDING_GLOBAL_CONFIG) + util.display_message( + "Global configuration updated and saved.", history=True + ) + else: + util.display_message("Failed to save global configuration.", error=True) + else: + util.display_message( + "Global configuration changes discarded.", history=True + ) + except Exception as e: + util.display_message(f"Error confirming global configuration: {e}", error=True) + finally: + _VIMINI_PENDING_GLOBAL_CONFIG = None + _VIMINI_ORIGINAL_GLOBAL_CONFIG = None + _VIMINI_GLOBAL_CONFIG_PATH = None + _GLOBAL_CONFIG_LINE_MAP = {} + + def _refresh_config_buffer(win=None, line_num=None, col=None, buf_nr=None): target_buf = None if buf_nr: diff --git a/python3/vimini/main.py b/python3/vimini/main.py index f7d4cd6..3e5992c 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -22,7 +22,7 @@ files_command, restore_context_files, ) -from vimini.config import config_command +from vimini.config import config_command, global_config_command from vimini.handler import ( BaseChannelHandler, register_handler, @@ -132,6 +132,40 @@ def send_setup(): return util.send_channel_request(req, silent=False) +def apply_backend_config(cfg, backend_name, persist=True): + """ + Applies a backend from a loaded global config to the current Vim/runtime state. + Optionally persists active_backend in the config file. + """ + from vimini.common.config import set_active_backend + + backends = cfg.get("backends", {}) if isinstance(cfg, dict) else {} + if backend_name not in backends: + available = list(backends.keys()) + raise ValueError(f"Backend '{backend_name}' not found. Available: {available}") + + if persist: + set_active_backend(backend_name) + + target_cfg = backends[backend_name] + util._BACKEND = backend_name + try: + vim.command(f"let g:vimini_backend = {json.dumps(backend_name)}") + except Exception: + pass + + if target_cfg.get("default_model"): + util._MODEL = target_cfg.get("default_model") + util._MODEL_NAME = None + try: + vim.command(f"let g:vimini_model = {json.dumps(util._MODEL)}") + except Exception: + pass + if target_cfg.get("api_key_file"): + util._API_KEY_FILE = os.path.expanduser(target_cfg.get("api_key_file")) + send_setup() + + def backend_command(backend_name=None): """ Handles :ViminiBackend [backend_name]. @@ -142,7 +176,6 @@ def backend_command(backend_name=None): from vimini.common.config import ( load_global_config, get_active_backend, - set_active_backend, ) try: @@ -176,33 +209,10 @@ def backend_command(backend_name=None): return try: - set_active_backend(backend_name_str) + apply_backend_config(cfg, backend_name_str, persist=True) except Exception as e: util.display_message(f"Error setting active backend: {e}", error=True) return - - target_cfg = backends[backend_name_str] - default_model = target_cfg.get("default_model") - api_key_file = target_cfg.get("api_key_file") - - util._BACKEND = backend_name_str - try: - vim.command(f"let g:vimini_backend = '{backend_name_str}'") - except Exception: - pass - - if default_model: - util._MODEL = default_model - util._MODEL_NAME = None - try: - vim.command(f"let g:vimini_model = '{default_model}'") - except Exception: - pass - - if api_key_file: - util._API_KEY_FILE = os.path.expanduser(api_key_file) - - send_setup() util.display_message( f"Switched backend to {backend_name_str} (model: {util._MODEL})", history=True, @@ -419,6 +429,9 @@ def help(command_name=None): ":ViminiConfig", " Opens a guided editor to configure project settings (build/test/format commands, worktree directory).", "", + ":ViminiGlobalConfig", + " Opens a guided editor to configure global settings and model provider backends.", + "", ":ViminiReview [-c ] [--pr ] [--mr ] [--remote ] [--security] [--save[=]] [{prompt}]", " Reviews code in current buffer, git objects, GitHub PR, or GitLab MR.", " -c : Review changes in git ref (can be a range for batch review).", diff --git a/tests/test_global_config_ui.py b/tests/test_global_config_ui.py new file mode 100644 index 0000000..1210dad --- /dev/null +++ b/tests/test_global_config_ui.py @@ -0,0 +1,103 @@ +import os +import sys +from unittest.mock import MagicMock, patch + +sys.path.insert( + 0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "python3")) +) + + +class MockBuffer(list): + def __init__(self, initial=None, number=1, name=""): + super().__init__(initial or []) + self.number = number + self.name = name + self.options = {} + self.vars = {} + + +def _sample_global_config(): + return { + "active_backend": "Gemini", + "backends": { + "Gemini": { + "api": "genai", + "api_key_file": "~/.config/gemini.token", + "default_model": "gemini-3.8-flash", + }, + "Proxy": { + "api": "openai", + "base_url": "https://proxy.example/v1", + "api_key_file": "~/.config/proxy.token", + "default_model": "gpt-4o", + }, + }, + } + + +def test_draw_global_config_listing_contains_backends(): + from vimini.config import _draw_global_config_listing + + lines, line_map = _draw_global_config_listing( + _sample_global_config(), "/tmp/vimini/config.json" + ) + text = "\n".join(lines) + + assert "Vimini Global Configuration" in text + assert "active_backend = Gemini" in text + assert "* Gemini" in text + assert " Proxy" in text + assert "base_url = https://proxy.example/v1" in text + assert ("global", "active_backend") in line_map.values() + assert ("backend", "Proxy") in line_map.values() + assert ("backend_field", "Proxy", "default_model") in line_map.values() + + +def test_global_config_command_buffer_options_and_vars(tmp_path): + import vim + from vimini import config + + mock_buf = MockBuffer() + mock_win = MagicMock() + mock_win.cursor = (1, 0) + mock_win.buffer = mock_buf + mock_win.options = {} + + config_path = str(tmp_path / "config.json") + with ( + patch.object(vim.current, "buffer", mock_buf), + 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()), + ): + vim.command.reset_mock() + config.global_config_command() + + assert mock_buf.options.get("buftype") == "nofile" + assert mock_buf.options.get("swapfile") is False + assert mock_buf.options.get("modifiable") is False + assert mock_buf.options.get("readonly") is True + assert mock_buf.vars.get("vimini_global_config_path") == config_path + assert any("Vimini Global Configuration" in line for line in mock_buf) + + +def test_apply_backend_config_updates_runtime(): + 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.main.send_setup") as mock_send_setup, + ): + main.apply_backend_config(cfg, "Proxy", persist=True) + + mock_set_active.assert_called_once_with("Proxy") + assert util._BACKEND == "Proxy" + assert util._MODEL == "gpt-4o" + assert util._API_KEY_FILE == os.path.expanduser("~/.config/proxy.token") + mock_send_setup.assert_called_once() + assert any( + "g:vimini_backend" in call.args[0] for call in vim.command.call_args_list + ) From 2251702224f8b0f9ab7d77f72fcfee29bb2bda1b Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 22:13:45 -0400 Subject: [PATCH 07/19] Fix trailer to include just the model name Signed-off-by: Simo Sorce --- python3/vimini/commit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python3/vimini/commit.py b/python3/vimini/commit.py index 1a36d08..0cca9f1 100644 --- a/python3/vimini/commit.py +++ b/python3/vimini/commit.py @@ -1141,7 +1141,7 @@ def finalize_commit(): commit_cmd.append("--amend") if assistant: - trailer = f"Assisted-by: Gemini:{get_model_name()}" + trailer = f"Assisted-by: {get_model_name()}" if trailer not in content: content += f"\n\n{trailer}\n" From 2e95d1fcce70fa8df035555757081a66b2b1c5c0 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 22:34:12 -0400 Subject: [PATCH 08/19] Persist default models per backend Load the startup model from the active backend's global config instead of the deprecated Vim variable. Add a `D` action in the model list to save and switch the backend default model, update help and README docs, and cover the behavior with tests. Assisted-by: gpt-5.5 Signed-off-by: Simo Sorce --- README.md | 17 +++++--- plugin/vimini.vim | 6 +-- python3/vimini/main.py | 30 ++++++++----- python3/vimini/models.py | 84 ++++++++++++++++++++++++++++++++---- tests/test_models.py | 47 ++++++++++++++++++++ tests/test_vim_backend_ui.py | 28 ++++++++++++ 6 files changed, 181 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 7dbef38..52855a5 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,12 @@ customizations. YOUR_API_KEY_HERE ``` -2. **Default Model (`g:vimini_model`)**: - Specify the default Gemini model you want to use. The default is - `gemini-2.5-flash`. You can list available models using `:ViminiListModels`. - ```vim - let g:vimini_model = 'gemini-2.5-flash' " Or 'gemini-2.5-pro', etc. - ``` +2. **Default Model (`~/.var/vimini/config.json`)**: + The default model is configured per backend in the global Vimini configuration + file under each backend's `default_model` field. Setting `g:vimini_model` in + your Vim config is deprecated and no longer controls startup model selection. + Use `:ViminiGlobalConfig` or press `D` on a model in `:ViminiListModels` to + save it as the active backend's default model. 3. **Thinking Display (`g:vimini_thinking`)**: Control whether the AI's "thinking" process is displayed in a @@ -232,7 +232,10 @@ Vimini exposes several commands for interacting with the Gemini API: ### `:ViminiListModels` Lists all available Gemini models in a new split window. This is -useful for knowing which models you can set for `g:vimini_model`. +useful for viewing models available to the active backend. +Press `` on a supported model to switch to it for the current session. +Press `D` on a supported model to save it as the active backend's default model +in `~/.var/vimini/config.json` and switch to it immediately. ```vim :ViminiListModels diff --git a/plugin/vimini.vim b/plugin/vimini.vim index d333fd2..957697c 100644 --- a/plugin/vimini.vim +++ b/plugin/vimini.vim @@ -9,9 +9,6 @@ let g:loaded_vimini = 1 " Configuration: API Key file path let s:api_key_file = expand('~/.config/gemini.token') -" Configuration: Model name -let g:vimini_model = get(g:, 'vimini_model', 'gemini-3.6-flash') - " Configuration: Active Backend name let g:vimini_backend = get(g:, 'vimini_backend', '') @@ -63,10 +60,9 @@ try: from vimini import main api_key_file = vim.eval('s:api_key_file') - model = vim.eval('g:vimini_model') backend = vim.eval('g:vimini_backend') log_file = vim.eval('g:vimini_log_file') if vim.eval('g:vimini_logging') == 'on' else None - main.initialize(api_key_file=api_key_file, model=model, logfile=log_file, backend=backend if backend else None) + main.initialize(api_key_file=api_key_file, logfile=log_file, backend=backend if backend else None) from vimini import util if util._BACKEND: vim.command(f"let g:vimini_backend = '{util._BACKEND}'") diff --git a/python3/vimini/main.py b/python3/vimini/main.py index 3e5992c..c562681 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -31,30 +31,42 @@ ) -def initialize(api_key_file, model, logfile=None, backend=None): +def initialize(api_key_file, model=None, logfile=None, backend=None): """ - Initializes the plugin with the user's API key file path, model name, and - optional logfile path. + 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. This function is called from the plugin's Vimscript entry point. """ - from vimini.common.config import load_global_config, get_active_backend + from vimini.common.config import ( + DEFAULT_MODEL, + load_global_config, + get_active_backend, + ) try: - cfg = load_global_config(default_model=model, api_key_file=api_key_file) + cfg = load_global_config(api_key_file=api_key_file) if not backend: backend = get_active_backend(cfg) util._BACKEND = backend active_b_cfg = cfg.get("backends", {}).get(backend, {}) if active_b_cfg.get("api_key_file"): api_key_file = os.path.expanduser(active_b_cfg.get("api_key_file")) + active_model = active_b_cfg.get("default_model") or DEFAULT_MODEL except Exception as e: util.log_info(f"Failed to load global config during initialize: {e}") util._BACKEND = backend or "Gemini" + active_model = DEFAULT_MODEL util._API_KEY_FILE = api_key_file - util._MODEL = model + util._MODEL = active_model + util._MODEL_NAME = None util._GENAI_CLIENT = None # Reset client if key/model changes. util.set_logging(logfile) + try: + vim.command(f"let g:vimini_backend = {json.dumps(util._BACKEND)}") + vim.command(f"let g:vimini_model = {json.dumps(util._MODEL)}") + except Exception: + pass restore_context_files() if not util.get_api_key(): util.display_message( @@ -339,7 +351,6 @@ def reload_vimini(): from vimini import util as old_util api_key_file = old_util._API_KEY_FILE - model = old_util._MODEL job_counter = old_util._JOB_COUNTER log_file = None if old_util._LOGGER and old_util._LOGGER.handlers: @@ -351,7 +362,6 @@ def reload_vimini(): break except Exception: api_key_file = os.path.expanduser("~/.config/gemini.token") - model = vim.eval("get(g:, 'vimini_model', 'gemini-3.6-flash')") 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": @@ -365,7 +375,7 @@ def reload_vimini(): # Re-import main and re-initialize from vimini import main - main.initialize(api_key_file=api_key_file, model=model, logfile=log_file) + main.initialize(api_key_file=api_key_file, logfile=log_file) # Use the freshly imported util to display the message and restore _JOB_COUNTER from vimini import util as new_util @@ -405,7 +415,7 @@ def help(command_name=None): " Without arguments, echoes active backend and other available backends.", "", ":ViminiListModels", - " Lists all available Gemini models in a new split window.", + " Lists available models. Press D on a model to save it as the backend default.", "", ":ViminiChat", " Opens the chat buffer for interactive mode with Gemini.", diff --git a/python3/vimini/models.py b/python3/vimini/models.py index a0b10b6..e061e08 100644 --- a/python3/vimini/models.py +++ b/python3/vimini/models.py @@ -161,7 +161,7 @@ def build_models_buffer(models, current_model, backend_name=None, api_type=None) buffer_lines = [ f"| Vimini Available Models (Backend: {backend_name})", "|----------------------------------------------------------------------", - "| : switch model | q: close", + "| : switch model | D: set default | q: close", ] if api_type == "genai": buffer_lines.append( @@ -298,6 +298,9 @@ def show_models_list(models): vim.command( "nnoremap :py3 from vimini.models import select_model; select_model()" ) + vim.command( + "nnoremap D :py3 from vimini.models import set_default_model; set_default_model()" + ) vim.command("nnoremap q :q") try: @@ -315,6 +318,26 @@ def show_models_list(models): pass +def _get_cursor_model_entry(): + line_num, _ = vim.current.window.cursor + return _LINE_TO_MODEL.get(line_num) + + +def _normalized_model_name(entry): + model_name = entry.get("name", "") if entry else "" + if model_name.startswith("models/"): + model_name = model_name[7:] + return model_name + + +def _validate_selectable_entry(entry): + cat = entry.get("category") if entry else None + if cat != SUPPORTED_CATEGORY and cat != "Available Models": + util.display_message(f"Category '{cat}' is unsupported", error=True) + return False + return True + + def select_model(): global _LINE_TO_MODEL try: @@ -322,19 +345,14 @@ def select_model(): win = vim.current.window line_num, col = win.cursor - entry = _LINE_TO_MODEL.get(line_num) + entry = _get_cursor_model_entry() if not entry: return - cat = entry.get("category") - if cat != SUPPORTED_CATEGORY and cat != "Available Models": - util.display_message(f"Category '{cat}' is unsupported", error=True) + if not _validate_selectable_entry(entry): return - model_name = entry.get("name", "") - if model_name.startswith("models/"): - model_name = model_name[7:] - + model_name = _normalized_model_name(entry) util._MODEL = model_name util._MODEL_NAME = None vim.command(f"let g:vimini_model = '{model_name}'") @@ -355,3 +373,51 @@ def select_model(): except Exception as e: util.display_message(f"Error selecting model: {e}", error=True) + + +def set_default_model(): + global _LINE_TO_MODEL + try: + buf = vim.current.buffer + entry = _get_cursor_model_entry() + if not entry: + return + + if not _validate_selectable_entry(entry): + return + + 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 import main + + cfg = load_global_config() + backend_name = getattr(util, "_BACKEND", None) or get_active_backend(cfg) + backends = cfg.get("backends", {}) + if backend_name not in backends: + util.display_message( + f"Backend '{backend_name}' not found in global config", error=True + ) + return + + backends[backend_name]["default_model"] = model_name + save_global_config(cfg) + + util._MODEL = model_name + util._MODEL_NAME = None + vim.command(f"let g:vimini_model = '{model_name}'") + main.send_setup() + + vim.command("setlocal modifiable") + buffer_lines, _LINE_TO_MODEL, _ = build_models_buffer( + _VIMINI_MODELS, util._MODEL + ) + buf[:] = buffer_lines + vim.command("setlocal nomodifiable") + vim.command("redraw") + + util.display_message(f"Set default model for {backend_name} to {model_name}") + + except Exception as e: + util.display_message(f"Error setting default model: {e}", error=True) diff --git a/tests/test_models.py b/tests/test_models.py index 6e5c5f7..3dd40ad 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -12,6 +12,7 @@ categorize_model, build_models_buffer, select_model, + set_default_model, SUPPORTED_CATEGORY, _LINE_TO_MODEL, ) @@ -273,3 +274,49 @@ def test_select_unsupported_model_emits_error(genai_global_config): mock_display.assert_called_once_with( "Category 'Image Generation' is unsupported", error=True ) + + +def test_set_default_model_persists_and_switches(genai_global_config): + models = [ + {"name": "gemini-2.5-flash", "display_name": "Gemini 2.5 Flash"}, + {"name": "gemini-2.5-pro", "display_name": "Gemini 2.5 Pro"}, + ] + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": {"api": "genai", "default_model": "gemini-2.5-flash"}, + }, + } + util._BACKEND = "Gemini" + util._MODEL = "gemini-2.5-flash" + buffer_lines, line_map, _ = build_models_buffer( + models, current_model="gemini-2.5-flash" + ) + models_module._VIMINI_MODELS = models + models_module._LINE_TO_MODEL = line_map + + target_line = next( + ln for ln, item in line_map.items() if item and item["name"] == "gemini-2.5-pro" + ) + + import vim + + vim.current.window.cursor = (target_line, 3) + mock_buffer = list(buffer_lines) + + with ( + patch.object(vim.current, "buffer", mock_buffer), + patch("vimini.common.config.load_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, + ): + set_default_model() + + assert config["backends"]["Gemini"]["default_model"] == "gemini-2.5-pro" + assert util._MODEL == "gemini-2.5-pro" + mock_save_config.assert_called_once_with(config) + mock_setup.assert_called_once() + mock_display.assert_called_once_with( + "Set default model for Gemini to gemini-2.5-pro" + ) diff --git a/tests/test_vim_backend_ui.py b/tests/test_vim_backend_ui.py index a51a40e..ba61cfb 100644 --- a/tests/test_vim_backend_ui.py +++ b/tests/test_vim_backend_ui.py @@ -160,3 +160,31 @@ def test_build_models_buffer_genai_categories(): assert "Backend: Gemini" in header assert "[Multimodal & Text Generation]" in buffer_lines assert any("Only 'Multimodal & Text Generation'" in line for line in buffer_lines) + + +def test_initialize_uses_global_config_model_not_vim_config(): + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": { + "api": "genai", + "api_key_file": "~/.config/gemini.token", + "default_model": "gemini-global-default", + } + }, + } + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.context.restore_context_files"), + patch("vimini.util.get_api_key", return_value="token"), + ): + main.initialize( + api_key_file="~/.config/gemini.token", + model="deprecated-vim-config-model", + logfile=None, + ) + + assert util._BACKEND == "Gemini" + assert util._MODEL == "gemini-global-default" + assert util._API_KEY_FILE == os.path.expanduser("~/.config/gemini.token") From 47a88d37c52a3f954d5c266067c531d70a74f84a Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 22:51:55 -0400 Subject: [PATCH 09/19] Require backend API key files in config Require every backend to define a non-empty api_key_file in the global configuration and prevent setup messages from passing api_key or api_key_file values to the agent. Backend creation now loads keys only from the selected backend config and reports missing keys clearly. The Vim config UI blocks adding or clearing invalid key file settings, initialization surfaces invalid global config errors, and tests/docs were updated to cover the safer configuration flow. Assisted-by: gpt-5.5 Signed-off-by: Simo Sorce --- README.md | 4 +- python3/vimini/agent/server.py | 5 +- python3/vimini/common/backends/__init__.py | 35 +++++-------- python3/vimini/common/config.py | 4 ++ python3/vimini/config.py | 10 ++++ python3/vimini/main.py | 7 ++- tests/test_agent_server.py | 35 +++++++++++++ tests/test_backend_abstraction.py | 52 ++++++++++++++++--- tests/test_global_config.py | 28 ++++++++-- tests/test_models.py | 8 ++- tests/test_proxy_backend.py | 4 +- tests/test_vim_backend_ui.py | 60 ++++++++++++++++++---- 12 files changed, 205 insertions(+), 47 deletions(-) create mode 100644 tests/test_agent_server.py diff --git a/README.md b/README.md index 52855a5..de7a59c 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,9 @@ customizations. You can configure multiple backends (Google Gemini via `"api": "genai"`, or OpenAI-compatible proxies and endpoints via `"api": "openai"`). - You can also edit this file interactively from Vim with `:ViminiGlobalConfig`. + Every backend must specify an `api_key_file`; the background agent always + reads API keys from this global configuration file and never receives API + key paths through runtime setup messages. You can also edit this file interactively from Vim with `:ViminiGlobalConfig`. ### `:ViminiGlobalConfig` diff --git a/python3/vimini/agent/server.py b/python3/vimini/agent/server.py index bbec234..df8c766 100644 --- a/python3/vimini/agent/server.py +++ b/python3/vimini/agent/server.py @@ -61,7 +61,10 @@ def execute_function(req_id, method, params, result_queue, conn): try: if method == "setup": if isinstance(params, dict): - AGENT_CONFIG.update(params) + safe_params = dict(params) + safe_params.pop("api_key_file", None) + safe_params.pop("api_key", None) + AGENT_CONFIG.update(safe_params) result = {"status": "ok"} elif method == "list_models": client = get_client(config=AGENT_CONFIG) diff --git a/python3/vimini/common/backends/__init__.py b/python3/vimini/common/backends/__init__.py index 5a99f65..3c72fdc 100644 --- a/python3/vimini/common/backends/__init__.py +++ b/python3/vimini/common/backends/__init__.py @@ -37,49 +37,42 @@ def get_backend( Confines all backend selection logic within generic backend methods. """ if isinstance(config, dict) and "backends" in config: - backends = config.get("backends", {}) + global_cfg = config if backend_name is None: backend_name = ( config.get("active_backend") or config.get("backend") or "Gemini" ) else: - try: - global_cfg = load_global_config(config_path=config_path) - except Exception: - from vimini.common.config import create_default_global_config - - global_cfg = create_default_global_config() - backends = global_cfg.get("backends", {}) + global_cfg = load_global_config(config_path=config_path) 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) + backends = global_cfg.get("backends", {}) if backend_name not in backends: - if backend_name not in backends: - available = list(backends.keys()) - raise ValueError( - f"Backend '{backend_name}' not found in configuration. Available: {available}" - ) + available = list(backends.keys()) + raise ValueError( + f"Backend '{backend_name}' not found in configuration. Available: {available}" + ) backend_cfg = dict(backends[backend_name]) - if isinstance(config, dict): - # Allow runtime overrides from caller config if present - for k in ("api_key", "api_key_file", "base_url", "headers"): - if k in config and config[k] is not None: - backend_cfg[k] = config[k] - api = backend_cfg.get("api", "genai") if api_key is None: api_key = load_backend_api_key(backend_cfg) - target_key_file = api_key_file or backend_cfg.get("api_key_file") + if not api_key: + key_file = backend_cfg.get("api_key_file") + raise RuntimeError( + f"API key for backend '{backend_name}' could not be loaded from " + f"api_key_file '{key_file}'" + ) if api == "genai": return GenAIBackend( api_key=api_key, - api_key_file=target_key_file, + api_key_file=backend_cfg.get("api_key_file"), config=backend_cfg, ) elif api == "openai": diff --git a/python3/vimini/common/config.py b/python3/vimini/common/config.py index 70d6d3d..3877dd0 100644 --- a/python3/vimini/common/config.py +++ b/python3/vimini/common/config.py @@ -72,6 +72,10 @@ def validate_global_config(config): raise ValueError( f"Backend '{name}' 'api_key_file' must be a string path if specified" ) + if not isinstance(key_file, str) or not key_file.strip(): + raise ValueError( + f"Backend '{name}' must specify a non-empty 'api_key_file'" + ) return config diff --git a/python3/vimini/config.py b/python3/vimini/config.py index 069c1dc..2f4b024 100644 --- a/python3/vimini/config.py +++ b/python3/vimini/config.py @@ -394,6 +394,11 @@ def add_global_backend(): ) default_model = _to_str(vim.eval("input('Default model: ')")).strip() api_key_file = _to_str(vim.eval("input('API key file: ')")).strip() + if not api_key_file: + util.display_message( + "API key file is required for every backend.", error=True + ) + return backend_cfg = {"api": api} if api_key_file: backend_cfg["api_key_file"] = api_key_file @@ -428,6 +433,11 @@ def delete_or_clear_global_config_option(): if field == "api": util.display_message("Backend api cannot be cleared.", error=True) return + if field == "api_key_file": + util.display_message( + "Backend api_key_file cannot be cleared.", error=True + ) + return backends.setdefault(backend_name, {}).pop(field, None) _refresh_global_config_buffer(win, line_num, col) util.display_message(f"Cleared '{backend_name}.{field}'.") diff --git a/python3/vimini/main.py b/python3/vimini/main.py index c562681..1af4741 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -43,6 +43,7 @@ def initialize(api_key_file, model=None, logfile=None, backend=None): get_active_backend, ) + config_error = None try: cfg = load_global_config(api_key_file=api_key_file) if not backend: @@ -54,6 +55,7 @@ def initialize(api_key_file, model=None, logfile=None, backend=None): active_model = active_b_cfg.get("default_model") or DEFAULT_MODEL except Exception as e: util.log_info(f"Failed to load global config during initialize: {e}") + config_error = e util._BACKEND = backend or "Gemini" active_model = DEFAULT_MODEL @@ -68,6 +70,10 @@ def initialize(api_key_file, model=None, logfile=None, backend=None): except Exception: pass restore_context_files() + if config_error is not None: + util.display_message( + f"Global configuration is invalid: {config_error}", error=True + ) if not util.get_api_key(): util.display_message( "API key not found. Please store it in ~/.config/gemini.token.", error=True @@ -137,7 +143,6 @@ def send_setup(): "method": "setup", "params": { "backend": backend_name, - "api_key_file": util._API_KEY_FILE, "model": util._MODEL, }, } diff --git a/tests/test_agent_server.py b/tests/test_agent_server.py new file mode 100644 index 0000000..4d668e5 --- /dev/null +++ b/tests/test_agent_server.py @@ -0,0 +1,35 @@ +import os +import sys + +sys.path.insert( + 0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "python3")) +) + +from vimini.agent import server + + +def test_setup_strips_api_key_fields(): + server.AGENT_CONFIG.clear() + + class DummyQueue: + def __init__(self): + self.items = [] + + def put(self, item): + self.items.append(item) + + queue = DummyQueue() + params = { + "backend": "Gemini", + "model": "gemini-3.8-flash", + "api_key_file": "~/.config/gemini.token", + "api_key": "secret", + } + + server.execute_function("setup", "setup", params, queue, object()) + + assert server.AGENT_CONFIG == { + "backend": "Gemini", + "model": "gemini-3.8-flash", + } + assert queue.items[0][1][1]["result"] == {"status": "ok"} diff --git a/tests/test_backend_abstraction.py b/tests/test_backend_abstraction.py index 39042d3..4b253e8 100644 --- a/tests/test_backend_abstraction.py +++ b/tests/test_backend_abstraction.py @@ -48,7 +48,10 @@ def test_get_backend_genai(tmp_path): }, } - with patch("vimini.common.backends.genai_backend.get_client") as mock_get_client: + with ( + patch("vimini.common.backends.load_backend_api_key", return_value="fake-key"), + patch("vimini.common.backends.genai_backend.get_client") as mock_get_client, + ): backend = get_backend("Gemini", config=config) assert isinstance(backend, GenAIBackend) mock_get_client.assert_called_once() @@ -60,18 +63,27 @@ def test_get_backend_defaults_to_active(tmp_path): "backends": { "Gemini": { "api": "genai", + "api_key_file": "~/.config/gemini.token", "default_model": "gemini-3.8-flash", } }, } - with patch("vimini.common.backends.genai_backend.get_client"): + with ( + patch("vimini.common.backends.load_backend_api_key", return_value="fake-key"), + patch("vimini.common.backends.genai_backend.get_client"), + ): backend = get_backend(config=config) assert isinstance(backend, GenAIBackend) def test_get_backend_unknown(): - config = {"active_backend": "Gemini", "backends": {"Gemini": {"api": "genai"}}} + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"} + }, + } with pytest.raises(ValueError, match="Backend 'NonExistent' not found"): get_backend("NonExistent", config=config) @@ -79,21 +91,47 @@ def test_get_backend_unknown(): def test_get_backend_unsupported_api(): config = { "active_backend": "Custom", - "backends": {"Custom": {"api": "unknown_api"}}, + "backends": { + "Custom": { + "api": "unknown_api", + "api_key_file": "~/.config/custom.token", + } + }, } - with pytest.raises(ValueError, match="Unsupported API protocol 'unknown_api'"): + with ( + patch("vimini.common.backends.load_backend_api_key", return_value="fake-key"), + pytest.raises(ValueError, match="Unsupported API protocol 'unknown_api'"), + ): get_backend("Custom", config=config) def test_get_backend_openai(): config = { "active_backend": "Custom 1", - "backends": {"Custom 1": {"api": "openai"}}, + "backends": { + "Custom 1": {"api": "openai", "api_key_file": "~/.config/openai.token"} + }, } - backend = get_backend("Custom 1", config=config) + with patch("vimini.common.backends.load_backend_api_key", return_value="fake-key"): + backend = get_backend("Custom 1", config=config) assert isinstance(backend, ProxyBackend) +def test_get_backend_reports_missing_api_key(): + config = { + "active_backend": "Custom 1", + "backends": { + "Custom 1": {"api": "openai", "api_key_file": "~/.config/missing.token"} + }, + } + + with ( + patch("vimini.common.backends.load_backend_api_key", return_value=None), + pytest.raises(RuntimeError, match="API key for backend 'Custom 1'"), + ): + get_backend("Custom 1", config=config) + + def test_genai_backend_list_models(): mock_client = MagicMock() mock_model = MagicMock() diff --git a/tests/test_global_config.py b/tests/test_global_config.py index 0f906d0..ff9cf8e 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -123,6 +123,22 @@ 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", + "backends": {"Gemini": {"api": "genai"}}, + } + ) + + with pytest.raises(ValueError, match="must specify a non-empty 'api_key_file'"): + validate_global_config( + { + "active_backend": "Gemini", + "backends": {"Gemini": {"api": "genai", "api_key_file": ""}}, + } + ) + with pytest.raises(ValueError, match="'api_key_file' must be a string path"): validate_global_config( { @@ -155,8 +171,12 @@ def test_get_active_backend_config(): cfg = { "active_backend": "Proxy", "backends": { - "Proxy": {"api": "openai", "default_model": "gpt-4o"}, - "Gemini": {"api": "genai"}, + "Proxy": { + "api": "openai", + "api_key_file": "~/.config/proxy.token", + "default_model": "gpt-4o", + }, + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, }, } name, b_cfg = get_active_backend_config(cfg) @@ -173,8 +193,8 @@ def test_set_active_backend(tmp_path): data = { "active_backend": "Gemini", "backends": { - "Gemini": {"api": "genai"}, - "Custom 1": {"api": "openai"}, + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, + "Custom 1": {"api": "openai", "api_key_file": "~/.config/custom.token"}, }, } save_global_config(data, config_path=str(config_file)) diff --git a/tests/test_models.py b/tests/test_models.py index 3dd40ad..0cd6bcf 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -22,7 +22,7 @@ GENAI_GLOBAL_CONFIG = { "active_backend": "Gemini", "backends": { - "Gemini": {"api": "genai"}, + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, }, } @@ -284,7 +284,11 @@ def test_set_default_model_persists_and_switches(genai_global_config): config = { "active_backend": "Gemini", "backends": { - "Gemini": {"api": "genai", "default_model": "gemini-2.5-flash"}, + "Gemini": { + "api": "genai", + "api_key_file": "~/.config/gemini.token", + "default_model": "gemini-2.5-flash", + }, }, } util._BACKEND = "Gemini" diff --git a/tests/test_proxy_backend.py b/tests/test_proxy_backend.py index e31d0f2..86bbc26 100644 --- a/tests/test_proxy_backend.py +++ b/tests/test_proxy_backend.py @@ -368,11 +368,13 @@ def test_get_backend_factory_resolves_proxy_backend(): "Custom 1": { "api": "openai", "base_url": "https://api.proxy.com/v1", + "api_key_file": "~/.config/proxyapi.token", "default_model": "gpt-4o", } }, } - backend = get_backend("Custom 1", config=config) + 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" diff --git a/tests/test_vim_backend_ui.py b/tests/test_vim_backend_ui.py index ba61cfb..0bf36cb 100644 --- a/tests/test_vim_backend_ui.py +++ b/tests/test_vim_backend_ui.py @@ -16,9 +16,9 @@ def test_backend_command_without_arguments(): config = { "active_backend": "Gemini", "backends": { - "Gemini": {"api": "genai"}, - "Custom 1": {"api": "openai"}, - "Custom 2": {"api": "openai"}, + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, + "Custom 1": {"api": "openai", "api_key_file": "~/.config/c1.token"}, + "Custom 2": {"api": "openai", "api_key_file": "~/.config/c2.token"}, }, } @@ -36,7 +36,7 @@ def test_backend_command_without_arguments_no_others(): config = { "active_backend": "Gemini", "backends": { - "Gemini": {"api": "genai"}, + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, }, } @@ -54,7 +54,11 @@ def test_backend_command_switch_success(): config = { "active_backend": "Gemini", "backends": { - "Gemini": {"api": "genai", "default_model": "gemini-3.8-flash"}, + "Gemini": { + "api": "genai", + "api_key_file": "~/.config/gemini.token", + "default_model": "gemini-3.8-flash", + }, "Custom 1": { "api": "openai", "default_model": "gpt-4o", @@ -87,7 +91,7 @@ def test_backend_command_switch_invalid(): config = { "active_backend": "Gemini", "backends": { - "Gemini": {"api": "genai"}, + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, }, } @@ -105,9 +109,9 @@ def test_get_available_backends(): config = { "active_backend": "Gemini", "backends": { - "Gemini": {"api": "genai"}, - "Custom 1": {"api": "openai"}, - "Custom 2": {"api": "openai"}, + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, + "Custom 1": {"api": "openai", "api_key_file": "~/.config/c1.token"}, + "Custom 2": {"api": "openai", "api_key_file": "~/.config/c2.token"}, }, } @@ -188,3 +192,41 @@ 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") + + +def test_send_setup_does_not_send_api_key_file(): + util._BACKEND = "Gemini" + util._MODEL = "gemini-3.8-flash" + util._API_KEY_FILE = os.path.expanduser("~/.config/gemini.token") + + with ( + patch("vimini.handler.register_handler"), + patch("vimini.util.send_channel_request") as mock_send, + ): + main.send_setup() + + req = mock_send.call_args[0][0] + params = req["params"] + assert params == {"backend": "Gemini", "model": "gemini-3.8-flash"} + assert "api_key_file" not in params + assert "api_key" not in params + + +def test_initialize_reports_invalid_global_config(): + with ( + patch( + "vimini.common.config.load_global_config", + side_effect=ValueError( + "Backend 'Gemini' must specify a non-empty 'api_key_file'" + ), + ), + patch("vimini.context.restore_context_files"), + patch("vimini.util.get_api_key", return_value="token"), + patch("vimini.util.display_message") as mock_display, + ): + main.initialize(api_key_file="~/.config/gemini.token", logfile=None) + + mock_display.assert_called_once_with( + "Global configuration is invalid: Backend 'Gemini' must specify a non-empty 'api_key_file'", + error=True, + ) From 8480d7597869024244da6bd9ba447273c80ec550 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 22:57:37 -0400 Subject: [PATCH 10/19] Use popup dialogs for config confirmations Replace standard Vim confirm() dialogs with styled popup windows when deleting backends and saving global configuration changes. This provides a more consistent visual interface and interaction flow. Unit tests have been added to cover the new popup dialogs. Assisted-by: Gemini 3.8 Flash Signed-off-by: Simo Sorce --- python3/vimini/config.py | 114 ++++++++++++++++++---- tests/test_global_config_ui.py | 167 +++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 16 deletions(-) diff --git a/python3/vimini/config.py b/python3/vimini/config.py index 2f4b024..6306d81 100644 --- a/python3/vimini/config.py +++ b/python3/vimini/config.py @@ -417,6 +417,60 @@ def add_global_backend(): util.display_message(f"Error adding backend: {e}", error=True) +def prompt_delete_backend_dialog(backend_name): + """ + Asks the user via a dialog if they want to delete a backend configuration. + Returns True if user confirmed, False otherwise. + """ + popup_content = [ + f"Delete backend '{backend_name}'?", + "This configuration will be permanently removed.", + "", + "---", + "Confirm deletion? [y/n]", + ] + popup_options = { + "title": " Delete Backend ", + "line": 0, + "col": 0, + "minwidth": 40, + "maxwidth": 80, + "padding": [1, 2, 1, 2], + "border": [1, 1, 1, 1], + "borderchars": ["─", "│", "─", "│", "╭", "╮", "╯", "╰"], + "close": "none", + "zindex": 200, + } + + confirmed = False + try: + popup_id = vim.eval( + f"popup_create({json.dumps(popup_content)}, {popup_options})" + ) + vim.command("redraw!") + try: + answer_code = vim.eval("getchar()") + try: + answer_char = chr(int(answer_code)) + except (ValueError, TypeError): + answer_char = str(answer_code) if answer_code else "" + if answer_char.lower() == "y": + confirmed = True + finally: + try: + p_id = int(popup_id) + except (ValueError, TypeError): + p_id = 1 + if p_id > 0: + vim.eval(f"popup_close({p_id})") + vim.command("redraw!") + except Exception as e: + util.log_info(f"Popup creation failed: {e}") + confirmed = False + + return confirmed + + def delete_or_clear_global_config_option(): global _VIMINI_PENDING_GLOBAL_CONFIG try: @@ -448,15 +502,7 @@ def delete_or_clear_global_config_option(): "Cannot delete the last configured backend.", error=True ) return - confirmed = ( - int( - vim.eval( - f"confirm({_vim_string(f'Delete backend {backend_name}?')}, '&Yes\\n&No', 2)" - ) - ) - == 1 - ) - if confirmed: + if prompt_delete_backend_dialog(backend_name): backends.pop(backend_name, None) if _VIMINI_PENDING_GLOBAL_CONFIG.get("active_backend") == backend_name: _VIMINI_PENDING_GLOBAL_CONFIG["active_backend"] = next( @@ -1314,14 +1360,50 @@ def confirm_global_config(): util.display_message(f"Global configuration is invalid: {e}", error=True) return - confirmed = ( - int( - vim.eval( - "confirm('Save global configuration changes?', '&Yes\\n&No', 2)" - ) + popup_content = [ + "Save global configuration changes?", + "", + "---", + "Accept changes? [y/n]", + ] + popup_options = { + "title": " Confirm Configuration ", + "line": 0, + "col": 0, + "minwidth": 40, + "maxwidth": 80, + "padding": [1, 2, 1, 2], + "border": [1, 1, 1, 1], + "borderchars": ["─", "│", "─", "│", "╭", "╮", "╯", "╰"], + "close": "none", + "zindex": 200, + } + confirmed = False + try: + popup_id = vim.eval( + f"popup_create({json.dumps(popup_content)}, {popup_options})" ) - == 1 - ) + vim.command("redraw!") + try: + answer_code = vim.eval("getchar()") + try: + answer_char = chr(int(answer_code)) + except (ValueError, TypeError): + answer_char = str(answer_code) if answer_code else "" + if answer_char.lower() == "y": + confirmed = True + finally: + try: + p_id = int(popup_id) + except (ValueError, TypeError): + p_id = 1 + if p_id > 0: + vim.eval(f"popup_close({p_id})") + vim.command("redraw!") + except Exception as e: + util.log_info(f"Popup creation failed: {e}") + confirmed = False + if confirmed: if save_global_config( _VIMINI_PENDING_GLOBAL_CONFIG, config_path=_VIMINI_GLOBAL_CONFIG_PATH diff --git a/tests/test_global_config_ui.py b/tests/test_global_config_ui.py index 1210dad..876859c 100644 --- a/tests/test_global_config_ui.py +++ b/tests/test_global_config_ui.py @@ -1,3 +1,4 @@ +import copy import os import sys from unittest.mock import MagicMock, patch @@ -101,3 +102,169 @@ def test_apply_backend_config_updates_runtime(): assert any( "g:vimini_backend" in call.args[0] for call in vim.command.call_args_list ) + + +def test_prompt_delete_backend_dialog_confirm(): + import vim + from vimini.config import prompt_delete_backend_dialog + + def mock_vim_eval(expr): + if "popup_create" in expr: + assert "Delete backend 'CustomBackend'?" in expr + assert "Delete Backend" in expr + return 101 + if "getchar" in expr: + return ord("y") + if "popup_close" in expr: + return 0 + return "" + + with patch.object(vim, "eval", side_effect=mock_vim_eval): + assert prompt_delete_backend_dialog("CustomBackend") is True + + +def test_prompt_delete_backend_dialog_reject(): + import vim + from vimini.config import prompt_delete_backend_dialog + + def mock_vim_eval(expr): + if "popup_create" in expr: + return 101 + if "getchar" in expr: + return ord("n") + if "popup_close" in expr: + return 0 + return "" + + with patch.object(vim, "eval", side_effect=mock_vim_eval): + assert prompt_delete_backend_dialog("CustomBackend") is False + + +def test_prompt_delete_backend_dialog_exception(): + import vim + from vimini.config import prompt_delete_backend_dialog + + with patch.object(vim, "eval", side_effect=Exception("popup failed")): + assert prompt_delete_backend_dialog("CustomBackend") is False + + +def test_delete_backend_option_confirmed(): + import vim + from vimini import config + + config._VIMINI_PENDING_GLOBAL_CONFIG = _sample_global_config() + 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", "Proxy"), + ), + patch( + "vimini.config.prompt_delete_backend_dialog", return_value=True + ) as mock_dialog, + patch("vimini.config._refresh_global_config_buffer"), + patch("vimini.util.display_message") as mock_msg, + ): + config.delete_or_clear_global_config_option() + + mock_dialog.assert_called_once_with("Proxy") + assert "Proxy" not in config._VIMINI_PENDING_GLOBAL_CONFIG["backends"] + mock_msg.assert_called_once_with("Deleted backend 'Proxy'.") + + +def test_delete_backend_option_rejected(): + import vim + from vimini import config + + config._VIMINI_PENDING_GLOBAL_CONFIG = _sample_global_config() + 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", "Proxy"), + ), + patch( + "vimini.config.prompt_delete_backend_dialog", return_value=False + ) as mock_dialog, + patch("vimini.config._refresh_global_config_buffer"), + patch("vimini.util.display_message") as mock_msg, + ): + config.delete_or_clear_global_config_option() + + mock_dialog.assert_called_once_with("Proxy") + assert "Proxy" in config._VIMINI_PENDING_GLOBAL_CONFIG["backends"] + assert not any("Deleted backend" in str(c) for c in mock_msg.call_args_list) + + +def test_delete_backend_option_cannot_delete_last(): + import vim + from vimini import config + + single_backend_cfg = { + "active_backend": "Gemini", + "backends": { + "Gemini": { + "api": "genai", + "api_key_file": "~/.config/gemini.token", + } + }, + } + config._VIMINI_PENDING_GLOBAL_CONFIG = single_backend_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", "Gemini"), + ), + patch("vimini.config.prompt_delete_backend_dialog") as mock_dialog, + patch("vimini.util.display_message") as mock_msg, + ): + config.delete_or_clear_global_config_option() + + mock_dialog.assert_not_called() + assert "Gemini" in config._VIMINI_PENDING_GLOBAL_CONFIG["backends"] + mock_msg.assert_called_once_with( + "Cannot delete the last configured backend.", error=True + ) + + +def test_confirm_global_config_dialog_confirmed(): + import vim + from vimini import config + + orig = _sample_global_config() + pending = copy.deepcopy(orig) + pending["active_backend"] = "Proxy" + + config._VIMINI_ORIGINAL_GLOBAL_CONFIG = orig + config._VIMINI_PENDING_GLOBAL_CONFIG = pending + config._VIMINI_GLOBAL_CONFIG_PATH = "/tmp/test_config.json" + + def mock_vim_eval(expr): + if "popup_create" in expr: + assert "Save global configuration changes?" in expr + return 201 + if "getchar" in expr: + return ord("y") + if "popup_close" in expr: + return 0 + return "" + + with ( + patch.object(vim, "eval", side_effect=mock_vim_eval), + patch("vimini.config.save_global_config", return_value=True) as mock_save, + ): + config.confirm_global_config() + mock_save.assert_called_once_with(pending, config_path="/tmp/test_config.json") From d02daffa4f0f16c81d5d19754570c6082b94906f Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 23:02:21 -0400 Subject: [PATCH 11/19] Add proxy API backend TODO Document future work for proxy/API backend support, including native Anthropic integration and OpenAI-compatible Files API handling for context files. Assisted-by: gpt-5.5 Signed-off-by: Simo Sorce --- PROXY-API-TODO.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 PROXY-API-TODO.md diff --git a/PROXY-API-TODO.md b/PROXY-API-TODO.md new file mode 100644 index 0000000..03fc06c --- /dev/null +++ b/PROXY-API-TODO.md @@ -0,0 +1,19 @@ +# Proxy API TODO + +Future work for Vimini's proxy/API backend support. + +## Anthropic API support + +- Add a native Anthropic backend in addition to the current OpenAI-compatible proxy backend. +- Map Vimini's generic chat, streaming, tools, and tool-response abstractions to Anthropic's Messages API. +- Handle Anthropic-specific streaming events and content block formats. +- Add global config support for `api: "anthropic"`, including model defaults and API key file loading. +- Add tests for model listing behavior, streaming responses, tool calls, and error handling. + +## OpenAI Files API for context files + +- Add support for OpenAI-compatible Files API uploads so proxy/openai backends can handle Vimini context files similarly to the Gemini backend. +- Track uploaded file IDs and metadata per backend/session where needed. +- Decide how context files should be attached to requests for supported providers and models. +- Add cleanup/list/delete support for uploaded files through `:ViminiFiles` where the provider supports it. +- Add graceful fallback behavior for providers that expose OpenAI-compatible chat APIs but do not support files. From dd4b7de830bd89726ec3fb78db4907e67a3cf56a Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 23:08:56 -0400 Subject: [PATCH 12/19] Fix streamed tool call merging Handle repeated or overlapping streamed function name deltas without duplicating tool names. Preserve provider metadata when tool call indexes are sparse by matching completed calls to their original indexes. Add tests for both cases. Assisted-by: gpt-5.5 Signed-off-by: Simo Sorce --- .../vimini/common/backends/proxy_backend.py | 40 ++++- tests/test_proxy_backend.py | 163 ++++++++++++++++++ 2 files changed, 197 insertions(+), 6 deletions(-) diff --git a/python3/vimini/common/backends/proxy_backend.py b/python3/vimini/common/backends/proxy_backend.py index 44c992b..6e05e79 100644 --- a/python3/vimini/common/backends/proxy_backend.py +++ b/python3/vimini/common/backends/proxy_backend.py @@ -147,6 +147,34 @@ def has_history(self) -> bool: def get_history(self) -> List[Any]: return [] + @staticmethod + def _merge_streamed_function_name(current: str, incoming: str) -> str: + """ + Merge streamed function name deltas without duplicating repeated names. + + Most providers stream function names as either one complete value or as + chunks. Some OpenAI-compatible providers repeat the complete function + name in subsequent deltas. Preserve chunked names while avoiding + corruption such as "read_fileread_file". + """ + if not incoming: + return current or "" + if not current: + return incoming + if incoming == current: + return current + if incoming.startswith(current): + return incoming + if current.endswith(incoming): + return current + + max_overlap = min(len(current), len(incoming)) + for overlap in range(max_overlap, 0, -1): + if current.endswith(incoming[:overlap]): + return current + incoming[overlap:] + + return current + incoming + @staticmethod def _merge_streamed_tool_call( target: Dict[str, Any], source: Dict[str, Any] @@ -173,9 +201,9 @@ def _merge_streamed_tool_call( source_function = source.get("function") or {} target_function = target.setdefault("function", {}) if source_function.get("name"): - target_function["name"] = target_function.get( - "name", "" - ) + source_function.get("name", "") + target_function["name"] = ProxyChatSession._merge_streamed_function_name( + target_function.get("name", ""), source_function.get("name", "") + ) if source_function.get("arguments"): target_function["arguments"] = target_function.get( "arguments", "" @@ -297,13 +325,13 @@ def send_message_stream( asst_msg["content"] = accumulated_text if completed_tool_calls: asst_msg["tool_calls"] = [] - for i, tc in enumerate(completed_tool_calls): - raw_tool_call = dict(tool_call_chunks.get(i, {})) + for idx, tc in zip(sorted(tool_call_chunks.keys()), completed_tool_calls): + raw_tool_call = dict(tool_call_chunks.get(idx, {})) raw_function = dict(raw_tool_call.get("function", {})) raw_function["name"] = tc.name raw_function["arguments"] = json.dumps(tc.args) - raw_tool_call["id"] = tc.call_id or f"call_{i}" + raw_tool_call["id"] = tc.call_id or f"call_{idx}" raw_tool_call["type"] = raw_tool_call.get("type") or "function" raw_tool_call["function"] = raw_function asst_msg["tool_calls"].append(raw_tool_call) diff --git a/tests/test_proxy_backend.py b/tests/test_proxy_backend.py index 86bbc26..5edad15 100644 --- a/tests/test_proxy_backend.py +++ b/tests/test_proxy_backend.py @@ -361,6 +361,169 @@ def test_proxy_chat_session_preserves_provider_tool_metadata(): ) +def test_proxy_chat_session_preserves_metadata_for_sparse_tool_call_index(): + backend = ProxyBackend() + session = backend.create_chat( + model="gemini-via-openai-proxy", + tools=[{"name": "git_grep", "parameters": {}}], + ) + + first_turn = [ + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 2, + "id": "call_sparse", + "type": "function", + "function": { + "name": "git_grep", + "arguments": '{"pattern":"sparse"}', + "thought_signature": "sparse-signature", + }, + "provider_extra": "sparse-extra", + } + ] + } + } + ] + } + ] + first_resp = io.BytesIO( + b"".join( + [f"data: {json.dumps(chunk)}\n\n".encode("utf-8") for chunk in first_turn] + + [b"data: [DONE]\n\n"] + ) + ) + + with patch.object(backend, "_make_request", return_value=first_resp): + chunks = list(session.send_message_stream("Find sparse metadata")) + + assert chunks == [ + StreamChunk( + tool_calls=[ + ToolCall( + name="git_grep", + args={"pattern": "sparse"}, + call_id="call_sparse", + ) + ] + ) + ] + + second_resp = io.BytesIO( + b'data: {"choices": [{"delta": {"content": "done"}}]}\n\ndata: [DONE]\n\n' + ) + tool_response = ToolResponse( + name="git_grep", + response={"result": "matches"}, + call_id="call_sparse", + ) + with patch.object(backend, "_make_request", return_value=second_resp) as mock_req: + list(session.send_message_stream([tool_response])) + + messages = mock_req.call_args[1]["data"]["messages"] + assistant_tool_call = messages[1]["tool_calls"][0] + assert assistant_tool_call["id"] == "call_sparse" + assert assistant_tool_call["provider_extra"] == "sparse-extra" + assert assistant_tool_call["function"]["thought_signature"] == "sparse-signature" + + +def test_proxy_chat_session_deduplicates_repeated_tool_call_name(): + backend = ProxyBackend() + session = backend.create_chat( + model="openai-compatible-model", + tools=[{"name": "read_file", "parameters": {}}], + ) + + first_turn = [ + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_repeat", + "function": { + "name": "read_file", + "arguments": '{"filepath":"a.py"}', + }, + } + ] + } + } + ] + }, + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "function": {"name": "read_file"}, + } + ] + } + } + ] + }, + ] + first_resp = io.BytesIO( + b"".join( + [f"data: {json.dumps(chunk)}\n\n".encode("utf-8") for chunk in first_turn] + + [b"data: [DONE]\n\n"] + ) + ) + + with patch.object(backend, "_make_request", return_value=first_resp): + chunks = list(session.send_message_stream("Read the file")) + + assert chunks == [ + StreamChunk( + tool_calls=[ + ToolCall( + name="read_file", + args={"filepath": "a.py"}, + call_id="call_repeat", + ) + ] + ) + ] + + second_resp = io.BytesIO( + b'data: {"choices": [{"delta": {"content": "done"}}]}\n\ndata: [DONE]\n\n' + ) + tool_response = ToolResponse( + name="read_file", + response="contents", + call_id="call_repeat", + ) + with patch.object(backend, "_make_request", return_value=second_resp) as mock_req: + list(session.send_message_stream([tool_response])) + + messages = mock_req.call_args[1]["data"]["messages"] + assistant_tool_call = messages[1]["tool_calls"][0] + assert assistant_tool_call["function"]["name"] == "read_file" + + +def test_merge_streamed_function_name_preserves_chunked_names(): + merge = ProxyChatSession._merge_streamed_function_name + + name = merge("", "read") + name = merge(name, "_file") + assert name == "read_file" + + # Overlapping chunks should be merged rather than duplicated. + assert merge("read_f", "file") == "read_file" + + # Repeated complete names should not be appended. + assert merge("read_file", "read_file") == "read_file" + + def test_get_backend_factory_resolves_proxy_backend(): config = { "active_backend": "Custom 1", From 78ad81ec003c30e4af28ae7911a904ed96ec17cb Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 26 Oct 2026 23:28:21 -0400 Subject: [PATCH 13/19] Validate backend API types Validate global backend configs before selecting a backend and reject unsupported API types early. This ensures invalid configs fail with clear errors and prevents unsupported protocols from reaching backend initialization. Assisted-by: gpt-5.5 Signed-off-by: Simo Sorce --- python3/vimini/common/backends/__init__.py | 2 ++ python3/vimini/common/config.py | 7 +++++++ tests/test_backend_abstraction.py | 16 ++++++++++++---- tests/test_global_config.py | 13 +++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/python3/vimini/common/backends/__init__.py b/python3/vimini/common/backends/__init__.py index 3c72fdc..9f4ca3b 100644 --- a/python3/vimini/common/backends/__init__.py +++ b/python3/vimini/common/backends/__init__.py @@ -22,6 +22,7 @@ load_global_config, get_active_backend, load_backend_api_key, + validate_global_config, ) @@ -37,6 +38,7 @@ def get_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 = ( diff --git a/python3/vimini/common/config.py b/python3/vimini/common/config.py index 3877dd0..d9cad6c 100644 --- a/python3/vimini/common/config.py +++ b/python3/vimini/common/config.py @@ -9,6 +9,7 @@ DEFAULT_BACKEND = "Gemini" DEFAULT_MODEL = "gemini-3.8-flash" DEFAULT_GEMINI_KEY_FILE = "~/.config/gemini.token" +SUPPORTED_API_TYPES = {"genai", "openai"} def get_global_config_path(): @@ -67,6 +68,12 @@ def validate_global_config(config): api = backend_info.get("api") if not isinstance(api, str) or not api.strip(): raise ValueError(f"Backend '{name}' must specify a non-empty 'api' type") + api = api.strip() + if api not in SUPPORTED_API_TYPES: + supported = ", ".join(sorted(SUPPORTED_API_TYPES)) + raise ValueError( + f"Backend '{name}' api '{api}' is not supported. Supported APIs: {supported}" + ) key_file = backend_info.get("api_key_file") if key_file is not None and not isinstance(key_file, str): raise ValueError( diff --git a/tests/test_backend_abstraction.py b/tests/test_backend_abstraction.py index 4b253e8..b1cb46d 100644 --- a/tests/test_backend_abstraction.py +++ b/tests/test_backend_abstraction.py @@ -98,10 +98,7 @@ def test_get_backend_unsupported_api(): } }, } - with ( - patch("vimini.common.backends.load_backend_api_key", return_value="fake-key"), - pytest.raises(ValueError, match="Unsupported API protocol 'unknown_api'"), - ): + with pytest.raises(ValueError, match="api 'unknown_api' is not supported"): get_backend("Custom", config=config) @@ -132,6 +129,17 @@ def test_get_backend_reports_missing_api_key(): get_backend("Custom 1", config=config) +def test_get_backend_surfaces_global_config_load_errors(): + with ( + patch( + "vimini.common.backends.load_global_config", + side_effect=ValueError("bad config"), + ), + pytest.raises(ValueError, match="bad config"), + ): + get_backend("Gemini") + + def test_genai_backend_list_models(): mock_client = MagicMock() mock_model = MagicMock() diff --git a/tests/test_global_config.py b/tests/test_global_config.py index ff9cf8e..64bd780 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -123,6 +123,19 @@ def test_validate_global_config_errors(): } ) + with pytest.raises(ValueError, match="api 'anthropic' is not supported"): + validate_global_config( + { + "active_backend": "Anthropic", + "backends": { + "Anthropic": { + "api": "anthropic", + "api_key_file": "~/.config/anthropic.token", + } + }, + } + ) + with pytest.raises(ValueError, match="must specify a non-empty 'api_key_file'"): validate_global_config( { From 8c139c8a952525fbef30b2efa9ed7dcfad9038b0 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Tue, 27 Oct 2026 00:28:51 -0400 Subject: [PATCH 14/19] Support keyless OpenAI backends Allow OpenAI-compatible backends to omit api_key_file by default, with requires_api_key available for remote providers that need credentials. Update validation, initialization warnings, backend creation, Vim config editing, docs, and tests accordingly. Improve proxy SSE parsing to handle multiline events, comments, trailing data, and [DONE] termination correctly. Assisted-by: gpt-5.5 Signed-off-by: Simo Sorce --- README.md | 8 +-- python3/vimini/common/backends/__init__.py | 3 +- .../vimini/common/backends/proxy_backend.py | 51 +++++++++++++----- python3/vimini/common/config.py | 30 ++++++++++- python3/vimini/config.py | 10 ---- python3/vimini/main.py | 8 ++- tests/test_backend_abstraction.py | 20 ++++++- tests/test_global_config.py | 12 +++++ tests/test_proxy_backend.py | 22 ++++++++ tests/test_vim_backend_ui.py | 52 +++++++++++++++++++ 10 files changed, 185 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index de7a59c..fc286e3 100644 --- a/README.md +++ b/README.md @@ -186,9 +186,11 @@ customizations. You can configure multiple backends (Google Gemini via `"api": "genai"`, or OpenAI-compatible proxies and endpoints via `"api": "openai"`). - Every backend must specify an `api_key_file`; the background agent always - reads API keys from this global configuration file and never receives API - key paths through runtime setup messages. You can also edit this file interactively from Vim with `:ViminiGlobalConfig`. + Backends that require credentials specify an `api_key_file`; Gemini requires + one, while OpenAI-compatible local/proxy endpoints may omit it unless + `requires_api_key` is set. The background agent always reads API keys from + this global configuration file and never receives API key paths through + runtime setup messages. You can also edit this file interactively from Vim with `:ViminiGlobalConfig`. ### `:ViminiGlobalConfig` diff --git a/python3/vimini/common/backends/__init__.py b/python3/vimini/common/backends/__init__.py index 9f4ca3b..c6838e2 100644 --- a/python3/vimini/common/backends/__init__.py +++ b/python3/vimini/common/backends/__init__.py @@ -23,6 +23,7 @@ get_active_backend, load_backend_api_key, validate_global_config, + backend_requires_api_key, ) @@ -64,7 +65,7 @@ def get_backend( if api_key is None: api_key = load_backend_api_key(backend_cfg) - if not api_key: + if backend_requires_api_key(backend_cfg) and not api_key: key_file = backend_cfg.get("api_key_file") raise RuntimeError( f"API key for backend '{backend_name}' could not be loaded from " diff --git a/python3/vimini/common/backends/proxy_backend.py b/python3/vimini/common/backends/proxy_backend.py index 6e05e79..2ebd2f8 100644 --- a/python3/vimini/common/backends/proxy_backend.py +++ b/python3/vimini/common/backends/proxy_backend.py @@ -392,23 +392,48 @@ def _make_request( def _read_sse_stream(self, resp) -> Iterator[Dict[str, Any]]: buffer = "" + done_sentinel = object() + + def parse_event(event_text: str): + data_lines = [] + for raw_event_line in event_text.splitlines(): + event_line = raw_event_line.rstrip("\r") + if not event_line or event_line.startswith(":"): + continue + if event_line.startswith("data:"): + data_lines.append(event_line[5:].lstrip()) + + if not data_lines: + return None + + data_str = "\n".join(data_lines) + if data_str == "[DONE]": + return done_sentinel + try: + return json.loads(data_str) + except json.JSONDecodeError: + return None + try: for raw_line in resp: line = raw_line.decode("utf-8", errors="replace") buffer += line - while "\n" in buffer: - line_str, buffer = buffer.split("\n", 1) - line_str = line_str.strip() - if not line_str or line_str.startswith(":"): - continue - if line_str.startswith("data:"): - data_str = line_str[5:].strip() - if data_str == "[DONE]": - return - try: - yield json.loads(data_str) - except json.JSONDecodeError: - continue + normalized = buffer.replace("\r\n", "\n").replace("\r", "\n") + while "\n\n" in normalized: + event_text, normalized = normalized.split("\n\n", 1) + event = parse_event(event_text) + if event is done_sentinel: + return + if event is not None: + yield event + buffer = normalized + + if buffer.strip(): + event = parse_event(buffer) + if event is done_sentinel: + return + if event is not None: + yield event finally: try: resp.close() diff --git a/python3/vimini/common/config.py b/python3/vimini/common/config.py index d9cad6c..7290188 100644 --- a/python3/vimini/common/config.py +++ b/python3/vimini/common/config.py @@ -12,6 +12,33 @@ SUPPORTED_API_TYPES = {"genai", "openai"} +def _to_bool(value, default=False): + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ("1", "true", "yes", "on"): + return True + if normalized in ("0", "false", "no", "off"): + return False + return default + + +def backend_requires_api_key(backend_config): + """ + Returns whether a backend requires an API key. + + Gemini always requires an API key. OpenAI-compatible endpoints default to not + requiring one so local/proxy endpoints can be used without spurious warnings; + remote providers can opt in with requires_api_key=true. + """ + if not isinstance(backend_config, dict): + return True + api = str(backend_config.get("api", "genai")).strip() + default = api == "genai" + return _to_bool(backend_config.get("requires_api_key"), default=default) + + def get_global_config_path(): """ Returns the path to the global configuration file ~/.var/vimini/config.json. @@ -74,12 +101,13 @@ def validate_global_config(config): raise ValueError( f"Backend '{name}' api '{api}' is not supported. Supported APIs: {supported}" ) + requires_key = backend_requires_api_key(backend_info) key_file = backend_info.get("api_key_file") if key_file is not None and not isinstance(key_file, str): raise ValueError( f"Backend '{name}' 'api_key_file' must be a string path if specified" ) - if not isinstance(key_file, str) or not key_file.strip(): + if requires_key and (not isinstance(key_file, str) or not key_file.strip()): raise ValueError( f"Backend '{name}' must specify a non-empty 'api_key_file'" ) diff --git a/python3/vimini/config.py b/python3/vimini/config.py index 6306d81..801e47c 100644 --- a/python3/vimini/config.py +++ b/python3/vimini/config.py @@ -394,11 +394,6 @@ def add_global_backend(): ) default_model = _to_str(vim.eval("input('Default model: ')")).strip() api_key_file = _to_str(vim.eval("input('API key file: ')")).strip() - if not api_key_file: - util.display_message( - "API key file is required for every backend.", error=True - ) - return backend_cfg = {"api": api} if api_key_file: backend_cfg["api_key_file"] = api_key_file @@ -487,11 +482,6 @@ def delete_or_clear_global_config_option(): if field == "api": util.display_message("Backend api cannot be cleared.", error=True) return - if field == "api_key_file": - util.display_message( - "Backend api_key_file cannot be cleared.", error=True - ) - return backends.setdefault(backend_name, {}).pop(field, None) _refresh_global_config_buffer(win, line_num, col) util.display_message(f"Cleared '{backend_name}.{field}'.") diff --git a/python3/vimini/main.py b/python3/vimini/main.py index 1af4741..826a95b 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -41,9 +41,11 @@ def initialize(api_key_file, model=None, logfile=None, backend=None): DEFAULT_MODEL, load_global_config, get_active_backend, + backend_requires_api_key, ) config_error = None + active_b_cfg = {} try: cfg = load_global_config(api_key_file=api_key_file) if not backend: @@ -74,9 +76,11 @@ def initialize(api_key_file, model=None, logfile=None, backend=None): util.display_message( f"Global configuration is invalid: {config_error}", error=True ) - if not util.get_api_key(): + if backend_requires_api_key(active_b_cfg) and not util.get_api_key(): + backend_name = util._BACKEND or "selected backend" + key_file = active_b_cfg.get("api_key_file") or api_key_file util.display_message( - "API key not found. Please store it in ~/.config/gemini.token.", error=True + f"API key for backend '{backend_name}' not found in {key_file}.", error=True ) diff --git a/tests/test_backend_abstraction.py b/tests/test_backend_abstraction.py index b1cb46d..1e10ee4 100644 --- a/tests/test_backend_abstraction.py +++ b/tests/test_backend_abstraction.py @@ -118,7 +118,11 @@ def test_get_backend_reports_missing_api_key(): config = { "active_backend": "Custom 1", "backends": { - "Custom 1": {"api": "openai", "api_key_file": "~/.config/missing.token"} + "Custom 1": { + "api": "openai", + "requires_api_key": True, + "api_key_file": "~/.config/missing.token", + } }, } @@ -129,6 +133,20 @@ def test_get_backend_reports_missing_api_key(): get_backend("Custom 1", config=config) +def test_get_backend_allows_openai_without_api_key_when_not_required(): + config = { + "active_backend": "Local", + "backends": { + "Local": {"api": "openai", "base_url": "http://localhost:11434/v1"} + }, + } + + with patch("vimini.common.backends.load_backend_api_key", return_value=None): + backend = get_backend("Local", config=config) + + assert isinstance(backend, ProxyBackend) + + def test_get_backend_surfaces_global_config_load_errors(): with ( patch( diff --git a/tests/test_global_config.py b/tests/test_global_config.py index 64bd780..0165d4c 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -144,6 +144,18 @@ def test_validate_global_config_errors(): } ) + validate_global_config( + { + "active_backend": "Local", + "backends": { + "Local": { + "api": "openai", + "base_url": "http://localhost:11434/v1", + } + }, + } + ) + with pytest.raises(ValueError, match="must specify a non-empty 'api_key_file'"): validate_global_config( { diff --git a/tests/test_proxy_backend.py b/tests/test_proxy_backend.py index 5edad15..0ca9d13 100644 --- a/tests/test_proxy_backend.py +++ b/tests/test_proxy_backend.py @@ -197,6 +197,28 @@ def test_proxy_backend_generate_content_stream(): assert chunks[2].text == " World" +def test_proxy_backend_read_sse_stream_multiline_events_and_trailing_data(): + backend = ProxyBackend() + stream = io.BytesIO( + b': comment ignored\ndata: {"first":\ndata: true}\n\ndata: {"second": true}' + ) + + events = list(backend._read_sse_stream(stream)) + + assert events == [{"first": True}, {"second": True}] + + +def test_proxy_backend_read_sse_stream_stops_on_done_event(): + backend = ProxyBackend() + stream = io.BytesIO( + b'data: {"first": true}\n\ndata: [DONE]\n\ndata: {"ignored": true}\n\n' + ) + + events = list(backend._read_sse_stream(stream)) + + assert events == [{"first": True}] + + def test_proxy_chat_session_streaming_and_tools(): backend = ProxyBackend() session = backend.create_chat( diff --git a/tests/test_vim_backend_ui.py b/tests/test_vim_backend_ui.py index 0bf36cb..ac01694 100644 --- a/tests/test_vim_backend_ui.py +++ b/tests/test_vim_backend_ui.py @@ -230,3 +230,55 @@ def test_initialize_reports_invalid_global_config(): "Global configuration is invalid: Backend 'Gemini' must specify a non-empty 'api_key_file'", error=True, ) + + +def test_initialize_does_not_warn_for_keyless_openai_backend(): + config = { + "active_backend": "Local", + "backends": { + "Local": { + "api": "openai", + "base_url": "http://localhost:11434/v1", + "default_model": "local-model", + } + }, + } + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.context.restore_context_files"), + patch("vimini.util.get_api_key", return_value=None), + patch("vimini.util.display_message") as mock_display, + ): + main.initialize(api_key_file="~/.config/gemini.token", logfile=None) + + assert util._BACKEND == "Local" + assert util._MODEL == "local-model" + mock_display.assert_not_called() + + +def test_initialize_warns_with_backend_specific_api_key_message(): + config = { + "active_backend": "Remote", + "backends": { + "Remote": { + "api": "openai", + "requires_api_key": True, + "api_key_file": "~/.config/remote.token", + "default_model": "gpt-compatible", + } + }, + } + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.context.restore_context_files"), + patch("vimini.util.get_api_key", return_value=None), + patch("vimini.util.display_message") as mock_display, + ): + main.initialize(api_key_file="~/.config/gemini.token", logfile=None) + + mock_display.assert_called_once_with( + "API key for backend 'Remote' not found in ~/.config/remote.token.", + error=True, + ) From 81fa6dc9fe9f28419179f919ff7f73f7de64fe7b Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Tue, 27 Oct 2026 00:43:53 -0400 Subject: [PATCH 15/19] Simplify streamed function name chunk merging Remove overlap deduplication logic from function name streaming. The previous overlap detection caused repeated characters to be inadvertently dropped during character-by-character streaming chunks. Assisted-by: Gemini 3.8 Flash Signed-off-by: Simo Sorce --- python3/vimini/common/backends/proxy_backend.py | 12 +----------- tests/test_proxy_backend.py | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/python3/vimini/common/backends/proxy_backend.py b/python3/vimini/common/backends/proxy_backend.py index 2ebd2f8..962c8ff 100644 --- a/python3/vimini/common/backends/proxy_backend.py +++ b/python3/vimini/common/backends/proxy_backend.py @@ -159,20 +159,10 @@ def _merge_streamed_function_name(current: str, incoming: str) -> str: """ if not incoming: return current or "" - if not current: + if not current or incoming == current: return incoming - if incoming == current: - return current if incoming.startswith(current): return incoming - if current.endswith(incoming): - return current - - max_overlap = min(len(current), len(incoming)) - for overlap in range(max_overlap, 0, -1): - if current.endswith(incoming[:overlap]): - return current + incoming[overlap:] - return current + incoming @staticmethod diff --git a/tests/test_proxy_backend.py b/tests/test_proxy_backend.py index 0ca9d13..a4d3c88 100644 --- a/tests/test_proxy_backend.py +++ b/tests/test_proxy_backend.py @@ -536,11 +536,21 @@ def test_merge_streamed_function_name_preserves_chunked_names(): merge = ProxyChatSession._merge_streamed_function_name name = merge("", "read") + assert name == "read" + assert merge(name, "") == "read" name = merge(name, "_file") assert name == "read_file" - # Overlapping chunks should be merged rather than duplicated. - assert merge("read_f", "file") == "read_file" + # Character-by-character streaming preserves repeated characters across boundaries + chars = list("apply_patch") + char_merged = "" + for c in chars: + char_merged = merge(char_merged, c) + assert char_merged == "apply_patch" + + assert merge("ap", "p") == "app" + assert merge("get_git_com", "m") == "get_git_comm" + assert merge("edit", "text") == "edittext" # Repeated complete names should not be appended. assert merge("read_file", "read_file") == "read_file" From c5bea740062de25b3949bde6d06b0f8e22ba8e05 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Tue, 27 Oct 2026 00:48:03 -0400 Subject: [PATCH 16/19] Improve argument handling for ViminiBackend Update the ViminiBackend command definition to accept any number of arguments using instead of . In the backend command handler, strip whitespace before validating the backend name so whitespace-only arguments are treated as empty and correctly show the active backend. Assisted-by: Gemini 3.8 Flash Signed-off-by: Simo Sorce --- plugin/vimini.vim | 2 +- python3/vimini/main.py | 4 ++-- tests/test_vim_backend_ui.py | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/plugin/vimini.vim b/plugin/vimini.vim index 957697c..638070c 100644 --- a/plugin/vimini.vim +++ b/plugin/vimini.vim @@ -124,7 +124,7 @@ function! ViminiBackendComplete(ArgLead, CmdLine, CursorPos) return py3eval("main.get_available_backends(vim.eval('a:ArgLead'))") endfunction -command! -nargs=? -complete=customlist,ViminiBackendComplete ViminiBackend call ViminiBackend() +command! -nargs=* -complete=customlist,ViminiBackendComplete ViminiBackend call ViminiBackend() " Expose a function to Chat with Gemini function! ViminiChat() diff --git a/python3/vimini/main.py b/python3/vimini/main.py index 826a95b..8c84041 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -208,7 +208,8 @@ def backend_command(backend_name=None): active = get_active_backend(cfg) backends = cfg.get("backends", {}) - if not backend_name: + backend_name_str = str(backend_name).strip() if backend_name else "" + if not backend_name_str: others = [b for b in backends.keys() if b != active] if others: util.display_message( @@ -220,7 +221,6 @@ def backend_command(backend_name=None): ) return - backend_name_str = str(backend_name).strip() if backend_name_str not in backends: available = list(backends.keys()) util.display_message( diff --git a/tests/test_vim_backend_ui.py b/tests/test_vim_backend_ui.py index ac01694..d5dc14d 100644 --- a/tests/test_vim_backend_ui.py +++ b/tests/test_vim_backend_ui.py @@ -50,6 +50,24 @@ def test_backend_command_without_arguments_no_others(): assert "Gemini (no other backends configured)" in msg +def test_backend_command_without_arguments_whitespace(): + config = { + "active_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.util.display_message") as mock_display, + ): + main.backend_command(" ") + mock_display.assert_called_once() + msg = mock_display.call_args[0][0] + assert "Gemini (no other backends configured)" in msg + + def test_backend_command_switch_success(): config = { "active_backend": "Gemini", From 90d2021a401a0131800f16a3a88b5b625de5c2c1 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Tue, 27 Oct 2026 00:54:21 -0400 Subject: [PATCH 17/19] Improve backend API key and model handling Infer API key requirements for OpenAI backends when api_key_file is configured, and ensure active model and API key states are properly reset when switching backends to avoid leaking configuration. Additionally, bypass Gemini client queries in get_model_name for non-Gemini backends to prevent spurious API key errors when using keyless local models. Assisted-by: Gemini 3.8 Flash Signed-off-by: Simo Sorce --- python3/vimini/common/config.py | 9 +++-- python3/vimini/main.py | 23 ++++++----- python3/vimini/util.py | 15 +++++++ tests/test_backend_abstraction.py | 20 ++++++++++ tests/test_commit_files.py | 47 ++++++++++++++++++++++ tests/test_global_config.py | 51 ++++++++++++++++++++++++ tests/test_util_buffer.py | 39 +++++++++++++++++++ tests/test_vim_backend_ui.py | 65 +++++++++++++++++++++++++++++++ 8 files changed, 255 insertions(+), 14 deletions(-) diff --git a/python3/vimini/common/config.py b/python3/vimini/common/config.py index 7290188..bcaede1 100644 --- a/python3/vimini/common/config.py +++ b/python3/vimini/common/config.py @@ -28,14 +28,15 @@ def backend_requires_api_key(backend_config): """ Returns whether a backend requires an API key. - Gemini always requires an API key. OpenAI-compatible endpoints default to not - requiring one so local/proxy endpoints can be used without spurious warnings; - remote providers can opt in with requires_api_key=true. + Gemini always requires an API key. OpenAI-compatible endpoints default to + requiring one if api_key_file is defined and non-empty, and not requiring one + if omitted (e.g. for local keyless endpoints). Explicit requires_api_key + takes precedence if specified. """ if not isinstance(backend_config, dict): return True api = str(backend_config.get("api", "genai")).strip() - default = api == "genai" + default = api == "genai" or bool(backend_config.get("api_key_file")) return _to_bool(backend_config.get("requires_api_key"), default=default) diff --git a/python3/vimini/main.py b/python3/vimini/main.py index 8c84041..588106b 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -158,7 +158,7 @@ def apply_backend_config(cfg, backend_name, persist=True): Applies a backend from a loaded global config to the current Vim/runtime state. Optionally persists active_backend in the config file. """ - from vimini.common.config import set_active_backend + from vimini.common.config import set_active_backend, DEFAULT_MODEL backends = cfg.get("backends", {}) if isinstance(cfg, dict) else {} if backend_name not in backends: @@ -175,15 +175,18 @@ def apply_backend_config(cfg, backend_name, persist=True): except Exception: pass - if target_cfg.get("default_model"): - util._MODEL = target_cfg.get("default_model") - util._MODEL_NAME = None - try: - vim.command(f"let g:vimini_model = {json.dumps(util._MODEL)}") - except Exception: - pass - if target_cfg.get("api_key_file"): - util._API_KEY_FILE = os.path.expanduser(target_cfg.get("api_key_file")) + default_model = target_cfg.get("default_model") or DEFAULT_MODEL + util._MODEL = default_model + util._MODEL_NAME = None + try: + vim.command(f"let g:vimini_model = {json.dumps(util._MODEL)}") + except Exception: + pass + util._API_KEY_FILE = ( + os.path.expanduser(target_cfg["api_key_file"]) + if target_cfg.get("api_key_file") + else None + ) send_setup() diff --git a/python3/vimini/util.py b/python3/vimini/util.py index e72e34b..14c2ff9 100644 --- a/python3/vimini/util.py +++ b/python3/vimini/util.py @@ -223,6 +223,21 @@ def get_model_name(): if _MODEL_NAME is not None: return _MODEL_NAME + backend = _BACKEND + api_type = None + try: + from vimini.common.config import load_global_config, get_active_backend + + cfg = load_global_config() + backend = backend or get_active_backend(cfg) + b_cfg = cfg.get("backends", {}).get(backend, {}) + api_type = b_cfg.get("api", "genai") + except Exception: + pass + + if (backend and backend != "Gemini") or (api_type and api_type != "genai"): + return _MODEL + client = get_client() if not client: return _MODEL diff --git a/tests/test_backend_abstraction.py b/tests/test_backend_abstraction.py index 1e10ee4..2483e22 100644 --- a/tests/test_backend_abstraction.py +++ b/tests/test_backend_abstraction.py @@ -133,6 +133,26 @@ def test_get_backend_reports_missing_api_key(): get_backend("Custom 1", config=config) +def test_get_backend_reports_missing_api_key_when_requires_api_key_omitted(): + config = { + "active_backend": "Custom 1", + "backends": { + "Custom 1": { + "api": "openai", + "api_key_file": "~/.config/missing.token", + } + }, + } + + with ( + patch("vimini.common.backends.load_backend_api_key", return_value=None), + pytest.raises( + RuntimeError, match="API key for backend 'Custom 1' could not be loaded" + ), + ): + get_backend("Custom 1", config=config) + + def test_get_backend_allows_openai_without_api_key_when_not_required(): config = { "active_backend": "Local", diff --git a/tests/test_commit_files.py b/tests/test_commit_files.py index 62f10fb..c9d5554 100644 --- a/tests/test_commit_files.py +++ b/tests/test_commit_files.py @@ -19,6 +19,7 @@ _revert_amend, _commit_files_by_file, CommitChannelHandler, + util, ) @@ -1844,3 +1845,49 @@ def fake_send(req): check=True, ).stdout.strip() assert msg_after_6 == "Regenerated commit message" + + +def test_finalize_commit_with_keyless_backend_no_api_key_error(tmp_path): + import vim + + tmp_file = tmp_path / "commit_msg_keyless.txt" + repo_path = str(tmp_path) + content = ( + "Commit with keyless model\n\n# --- Diff stat ---\n# src/main.py | 1 +\n" + ) + tmp_file.write_text(content, encoding="utf-8") + + def mock_eval_fn(expr): + if "vimini_commit_tmp_file" in expr: + return str(tmp_file) + if "vimini_commit_repo_path" in expr: + return repo_path + if "vimini_commit_assistant" in expr: + return 1 + if "vimini_commit_files" in expr: + return json.dumps(["src/main.py"]) + return "" + + util._MODEL_NAME = None + util._BACKEND = "Local" + util._MODEL = "llama3" + util._API_KEY_FILE = None + + with ( + patch.object(vim, "eval", side_effect=mock_eval_fn), + patch("subprocess.run") as mock_run, + patch("vimini.util.get_client") as mock_get_client, + patch("vimini.util.display_message") as mock_display, + ): + mock_proc_ok = MagicMock(returncode=0, stdout="[main 123] done", stderr="") + mock_run.return_value = mock_proc_ok + + finalize_commit() + + # get_client should never be called, avoiding spurious API key error + assert not mock_get_client.called + err_calls = [c for c in mock_display.call_args_list if c.kwargs.get("error")] + assert len(err_calls) == 0 + # Verify trailer contains the keyless model name + commit_input = mock_run.call_args.kwargs.get("input", "") + assert "Assisted-by: llama3" in commit_input diff --git a/tests/test_global_config.py b/tests/test_global_config.py index 0165d4c..da125ae 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -20,6 +20,7 @@ get_active_backend_config, set_active_backend, load_backend_api_key, + backend_requires_api_key, ) @@ -260,3 +261,53 @@ def test_load_backend_api_key_empty_or_missing(tmp_path): empty_file = tmp_path / "empty.token" empty_file.write_text(" \n", encoding="utf-8") assert load_backend_api_key({"api_key_file": str(empty_file)}) is None + + +def test_backend_requires_api_key(): + # Non-dict defaults to True + assert backend_requires_api_key(None) is True + assert backend_requires_api_key("string") is True + + # GenAI backends require API key by default + assert backend_requires_api_key({"api": "genai"}) is True + assert ( + backend_requires_api_key({"api": "genai", "api_key_file": "~/.token"}) is True + ) + assert ( + backend_requires_api_key({"api": "genai", "requires_api_key": False}) is False + ) + + # OpenAI without api_key_file defaults to False + assert backend_requires_api_key({"api": "openai"}) is False + assert ( + backend_requires_api_key( + {"api": "openai", "base_url": "http://localhost:11434"} + ) + is False + ) + + # OpenAI with api_key_file defaults to True + assert ( + backend_requires_api_key( + {"api": "openai", "api_key_file": "~/.config/openai.token"} + ) + is True + ) + assert backend_requires_api_key({"api": "openai", "api_key_file": ""}) is False + + # Explicit requires_api_key overrides default + assert ( + backend_requires_api_key( + { + "api": "openai", + "api_key_file": "~/.config/openai.token", + "requires_api_key": False, + } + ) + is False + ) + assert backend_requires_api_key({"api": "openai", "requires_api_key": True}) is True + assert ( + backend_requires_api_key({"api": "openai", "requires_api_key": "yes"}) is True + ) + assert backend_requires_api_key({"api": "openai", "requires_api_key": "0"}) is False diff --git a/tests/test_util_buffer.py b/tests/test_util_buffer.py index 5126376..5451cd5 100644 --- a/tests/test_util_buffer.py +++ b/tests/test_util_buffer.py @@ -190,3 +190,42 @@ def test_review_assigns_job_id_to_non_dict_vars(): # Verify vimini_job_id was successfully assigned to vars assert review_buf.vars.get("vimini_job_id") == "99" + + +def test_get_model_name_non_gemini_backend_does_not_call_get_client(): + util._MODEL_NAME = None + util._BACKEND = "Local" + util._MODEL = "llama3" + + with patch("vimini.util.get_client") as mock_get_client: + name = util.get_model_name() + assert name == "llama3" + assert not mock_get_client.called + + +def test_get_model_name_cached(): + util._MODEL_NAME = "Cached Display Name" + util._BACKEND = "Gemini" + util._MODEL = "gemini-3.8-flash" + + with patch("vimini.util.get_client") as mock_get_client: + name = util.get_model_name() + assert name == "Cached Display Name" + assert not mock_get_client.called + + +def test_get_model_name_gemini_calls_client(): + util._MODEL_NAME = None + util._BACKEND = "Gemini" + util._MODEL = "gemini-3.8-flash" + + mock_client = MagicMock() + mock_info = MagicMock() + mock_info.display_name = "Gemini 3.8 Flash" + mock_client.models.get.return_value = mock_info + + with patch("vimini.util.get_client", return_value=mock_client): + name = util.get_model_name() + assert name == "Gemini 3.8 Flash" + assert util._MODEL_NAME == "Gemini 3.8 Flash" + mock_client.models.get.assert_called_once_with(model="gemini-3.8-flash") diff --git a/tests/test_vim_backend_ui.py b/tests/test_vim_backend_ui.py index d5dc14d..c98df3f 100644 --- a/tests/test_vim_backend_ui.py +++ b/tests/test_vim_backend_ui.py @@ -10,6 +10,7 @@ from vimini import main, util from vimini.models import build_models_buffer +from vimini.common.config import DEFAULT_MODEL def test_backend_command_without_arguments(): @@ -105,6 +106,44 @@ def test_backend_command_switch_success(): ) +def test_backend_command_switch_resets_key_and_model_when_missing(): + config = { + "active_backend": "Gemini", + "backends": { + "Gemini": { + "api": "genai", + "api_key_file": "~/.config/gemini.token", + "default_model": "gemini-3.8-flash", + }, + "Local": { + "api": "openai", + "base_url": "http://localhost:11434/v1", + }, + }, + } + + util._BACKEND = "Gemini" + util._MODEL = "gemini-3.8-flash" + util._API_KEY_FILE = os.path.expanduser("~/.config/gemini.token") + util._MODEL_NAME = "Cached Model Name" + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.common.config.set_active_backend") as mock_set_active, + 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") + assert util._BACKEND == "Local" + assert util._MODEL == DEFAULT_MODEL + assert util._MODEL_NAME is None + assert util._API_KEY_FILE is None + mock_send_setup.assert_called_once() + mock_display.assert_called_once() + + def test_backend_command_switch_invalid(): config = { "active_backend": "Gemini", @@ -300,3 +339,29 @@ def test_initialize_warns_with_backend_specific_api_key_message(): "API key for backend 'Remote' not found in ~/.config/remote.token.", error=True, ) + + +def test_initialize_warns_when_api_key_file_specified_without_requires_api_key(): + config = { + "active_backend": "Remote", + "backends": { + "Remote": { + "api": "openai", + "api_key_file": "~/.config/remote.token", + "default_model": "gpt-compatible", + } + }, + } + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.context.restore_context_files"), + patch("vimini.util.get_api_key", return_value=None), + patch("vimini.util.display_message") as mock_display, + ): + main.initialize(api_key_file="~/.config/gemini.token", logfile=None) + + mock_display.assert_called_once_with( + "API key for backend 'Remote' not found in ~/.config/remote.token.", + error=True, + ) From fd61a8a01eefeea8ca9d8ec51cc58f51c35544f4 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Tue, 27 Oct 2026 01:08:51 -0400 Subject: [PATCH 18/19] Support list prompts in ProxyBackend Allow `generate_content` and `generate_content_stream` to accept prompts passed as lists by joining non-empty items with double newlines. This improves compatibility with callers that supply prompts as multi-part lists, and includes unit tests covering both methods. Assisted-by: Gemini 3.8 Flash Signed-off-by: Simo Sorce --- .../vimini/common/backends/proxy_backend.py | 17 +++++++-- tests/test_proxy_backend.py | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/python3/vimini/common/backends/proxy_backend.py b/python3/vimini/common/backends/proxy_backend.py index 962c8ff..d83cceb 100644 --- a/python3/vimini/common/backends/proxy_backend.py +++ b/python3/vimini/common/backends/proxy_backend.py @@ -461,7 +461,7 @@ def list_models(self) -> List[Dict[str, Any]]: def generate_content( self, - prompt: str, + prompt: Any, model: str, system_instruction: Optional[str] = None, verbose: bool = False, @@ -469,9 +469,15 @@ def generate_content( config: Optional[Any] = None, ) -> str: messages: List[Dict[str, Any]] = [] + if isinstance(prompt, list): + prompt_str = "\n\n".join(str(p) for p in prompt if p) + elif isinstance(prompt, str): + prompt_str = prompt + else: + prompt_str = str(prompt) if system_instruction: messages.append({"role": "system", "content": system_instruction}) - messages.append({"role": "user", "content": prompt}) + messages.append({"role": "user", "content": prompt_str}) payload = { "model": model, @@ -498,7 +504,12 @@ def generate_content_stream( if system_instruction: messages.append({"role": "system", "content": system_instruction}) - prompt_str = prompt if isinstance(prompt, str) else str(prompt) + if isinstance(prompt, list): + prompt_str = "\n\n".join(str(p) for p in prompt if p) + elif isinstance(prompt, str): + prompt_str = prompt + else: + prompt_str = str(prompt) messages.append({"role": "user", "content": prompt_str}) payload = { diff --git a/tests/test_proxy_backend.py b/tests/test_proxy_backend.py index a4d3c88..456e858 100644 --- a/tests/test_proxy_backend.py +++ b/tests/test_proxy_backend.py @@ -197,6 +197,41 @@ def test_proxy_backend_generate_content_stream(): assert chunks[2].text == " World" +def test_proxy_backend_generate_content_stream_list_prompt(): + backend = ProxyBackend() + mock_resp = io.BytesIO( + b'data: {"choices": [{"delta": {"content": "ok"}}]}\n\ndata: [DONE]\n\n' + ) + + with patch.object(backend, "_make_request", return_value=mock_resp) as mock_req: + chunks = list( + backend.generate_content_stream(["part 1", "part 2", ""], model="gpt-4o") + ) + assert len(chunks) == 1 + assert chunks[0].text == "ok" + payload = mock_req.call_args[1]["data"] + messages = payload["messages"] + assert len(messages) == 1 + assert messages[0]["content"] == "part 1\n\npart 2" + + +def test_proxy_backend_generate_content_list_prompt(): + backend = ProxyBackend() + mock_resp_data = { + "choices": [{"message": {"role": "assistant", "content": "result"}}] + } + + with patch.object( + backend, "_make_request", return_value=mock_resp_data + ) as mock_req: + res = backend.generate_content(["line 1", "line 2"], model="gpt-4o") + assert res == "result" + payload = mock_req.call_args[1]["data"] + messages = payload["messages"] + assert len(messages) == 1 + assert messages[0]["content"] == "line 1\n\nline 2" + + def test_proxy_backend_read_sse_stream_multiline_events_and_trailing_data(): backend = ProxyBackend() stream = io.BytesIO( From e5d2aa25562034871b773dd425c2fee1d7983c0a Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Tue, 27 Oct 2026 01:20:46 -0400 Subject: [PATCH 19/19] Require models for OpenAI backends Validate that OpenAI-compatible backends define a non-empty default_model and use that model when initializing or switching backends. Avoid falling back to Gemini defaults for non-Gemini providers, reset cached GenAI clients on backend changes, and keep GenAI model display lookup behavior for custom Gemini backends. Assisted-by: gpt-5.5 Signed-off-by: Simo Sorce --- python3/vimini/common/config.py | 7 +++++ python3/vimini/main.py | 34 ++++++++++++++++++++---- python3/vimini/util.py | 4 ++- tests/test_backend_abstraction.py | 14 ++++++++-- tests/test_global_config.py | 20 +++++++++++++- tests/test_util_buffer.py | 44 ++++++++++++++++++++++++++++++- tests/test_vim_backend_ui.py | 36 ++++++++++++++++++++----- 7 files changed, 143 insertions(+), 16 deletions(-) diff --git a/python3/vimini/common/config.py b/python3/vimini/common/config.py index bcaede1..5b273c8 100644 --- a/python3/vimini/common/config.py +++ b/python3/vimini/common/config.py @@ -112,6 +112,13 @@ def validate_global_config(config): raise ValueError( f"Backend '{name}' must specify a non-empty 'api_key_file'" ) + default_model = backend_info.get("default_model") + if api == "openai" and ( + not isinstance(default_model, str) or not default_model.strip() + ): + raise ValueError( + f"Backend '{name}' must specify a non-empty 'default_model'" + ) return config diff --git a/python3/vimini/main.py b/python3/vimini/main.py index 588106b..5b4774f 100644 --- a/python3/vimini/main.py +++ b/python3/vimini/main.py @@ -31,6 +31,25 @@ ) +def _get_backend_default_model(backend_name, backend_cfg, default_model): + """ + Returns the configured model for a backend. + + GenAI backends can fall back to the built-in Gemini default for legacy + configs. OpenAI-compatible backends must name an explicit model so we do + not accidentally send Gemini model names to non-Gemini providers. + """ + configured_model = backend_cfg.get("default_model") if backend_cfg else None + if configured_model: + return configured_model + api = str((backend_cfg or {}).get("api", "genai")).strip() + if api == "openai": + raise ValueError( + f"Backend '{backend_name}' must specify a non-empty 'default_model'" + ) + return 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. @@ -52,9 +71,12 @@ def initialize(api_key_file, model=None, logfile=None, backend=None): backend = get_active_backend(cfg) util._BACKEND = backend active_b_cfg = cfg.get("backends", {}).get(backend, {}) - if active_b_cfg.get("api_key_file"): - api_key_file = os.path.expanduser(active_b_cfg.get("api_key_file")) - active_model = active_b_cfg.get("default_model") or DEFAULT_MODEL + api_key_file = ( + os.path.expanduser(active_b_cfg["api_key_file"]) + if active_b_cfg.get("api_key_file") + else None + ) + active_model = _get_backend_default_model(backend, active_b_cfg, DEFAULT_MODEL) except Exception as e: util.log_info(f"Failed to load global config during initialize: {e}") config_error = e @@ -165,19 +187,21 @@ def apply_backend_config(cfg, backend_name, persist=True): available = list(backends.keys()) raise ValueError(f"Backend '{backend_name}' not found. Available: {available}") + target_cfg = backends[backend_name] + default_model = _get_backend_default_model(backend_name, target_cfg, DEFAULT_MODEL) + if persist: set_active_backend(backend_name) - target_cfg = backends[backend_name] util._BACKEND = backend_name try: vim.command(f"let g:vimini_backend = {json.dumps(backend_name)}") except Exception: pass - default_model = target_cfg.get("default_model") or DEFAULT_MODEL util._MODEL = default_model util._MODEL_NAME = None + util._GENAI_CLIENT = None try: vim.command(f"let g:vimini_model = {json.dumps(util._MODEL)}") except Exception: diff --git a/python3/vimini/util.py b/python3/vimini/util.py index 14c2ff9..86f5c97 100644 --- a/python3/vimini/util.py +++ b/python3/vimini/util.py @@ -235,7 +235,9 @@ def get_model_name(): except Exception: pass - if (backend and backend != "Gemini") or (api_type and api_type != "genai"): + if api_type is not None and api_type != "genai": + return _MODEL + if api_type is None and backend and backend != "Gemini": return _MODEL client = get_client() diff --git a/tests/test_backend_abstraction.py b/tests/test_backend_abstraction.py index 2483e22..b37c12c 100644 --- a/tests/test_backend_abstraction.py +++ b/tests/test_backend_abstraction.py @@ -106,7 +106,11 @@ def test_get_backend_openai(): config = { "active_backend": "Custom 1", "backends": { - "Custom 1": {"api": "openai", "api_key_file": "~/.config/openai.token"} + "Custom 1": { + "api": "openai", + "api_key_file": "~/.config/openai.token", + "default_model": "gpt-compatible", + } }, } with patch("vimini.common.backends.load_backend_api_key", return_value="fake-key"): @@ -122,6 +126,7 @@ def test_get_backend_reports_missing_api_key(): "api": "openai", "requires_api_key": True, "api_key_file": "~/.config/missing.token", + "default_model": "gpt-compatible", } }, } @@ -140,6 +145,7 @@ def test_get_backend_reports_missing_api_key_when_requires_api_key_omitted(): "Custom 1": { "api": "openai", "api_key_file": "~/.config/missing.token", + "default_model": "gpt-compatible", } }, } @@ -157,7 +163,11 @@ def test_get_backend_allows_openai_without_api_key_when_not_required(): config = { "active_backend": "Local", "backends": { - "Local": {"api": "openai", "base_url": "http://localhost:11434/v1"} + "Local": { + "api": "openai", + "base_url": "http://localhost:11434/v1", + "default_model": "llama3", + } }, } diff --git a/tests/test_global_config.py b/tests/test_global_config.py index da125ae..04a2c6c 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -152,11 +152,25 @@ def test_validate_global_config_errors(): "Local": { "api": "openai", "base_url": "http://localhost:11434/v1", + "default_model": "llama3", } }, } ) + with pytest.raises(ValueError, match="must specify a non-empty 'default_model'"): + validate_global_config( + { + "active_backend": "Local", + "backends": { + "Local": { + "api": "openai", + "base_url": "http://localhost:11434/v1", + } + }, + } + ) + with pytest.raises(ValueError, match="must specify a non-empty 'api_key_file'"): validate_global_config( { @@ -220,7 +234,11 @@ def test_set_active_backend(tmp_path): "active_backend": "Gemini", "backends": { "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, - "Custom 1": {"api": "openai", "api_key_file": "~/.config/custom.token"}, + "Custom 1": { + "api": "openai", + "api_key_file": "~/.config/custom.token", + "default_model": "gpt-compatible", + }, }, } save_global_config(data, config_path=str(config_file)) diff --git a/tests/test_util_buffer.py b/tests/test_util_buffer.py index 5451cd5..d215a50 100644 --- a/tests/test_util_buffer.py +++ b/tests/test_util_buffer.py @@ -197,12 +197,54 @@ def test_get_model_name_non_gemini_backend_does_not_call_get_client(): util._BACKEND = "Local" util._MODEL = "llama3" - with patch("vimini.util.get_client") as mock_get_client: + config = { + "active_backend": "Local", + "backends": { + "Local": { + "api": "openai", + "base_url": "http://localhost:11434/v1", + "default_model": "llama3", + } + }, + } + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.util.get_client") as mock_get_client, + ): name = util.get_model_name() assert name == "llama3" assert not mock_get_client.called +def test_get_model_name_custom_genai_backend_calls_client(): + util._MODEL_NAME = None + util._BACKEND = "Work Gemini" + util._MODEL = "gemini-work" + + config = { + "active_backend": "Work Gemini", + "backends": { + "Work Gemini": { + "api": "genai", + "api_key_file": "~/.config/work-gemini.token", + "default_model": "gemini-work", + } + }, + } + mock_client = MagicMock() + mock_info = MagicMock() + mock_info.display_name = "Work Gemini Display" + mock_client.models.get.return_value = mock_info + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.util.get_client", return_value=mock_client), + ): + assert util.get_model_name() == "Work Gemini Display" + mock_client.models.get.assert_called_once_with(model="gemini-work") + + def test_get_model_name_cached(): util._MODEL_NAME = "Cached Display Name" util._BACKEND = "Gemini" diff --git a/tests/test_vim_backend_ui.py b/tests/test_vim_backend_ui.py index c98df3f..5a2162d 100644 --- a/tests/test_vim_backend_ui.py +++ b/tests/test_vim_backend_ui.py @@ -10,7 +10,6 @@ from vimini import main, util from vimini.models import build_models_buffer -from vimini.common.config import DEFAULT_MODEL def test_backend_command_without_arguments(): @@ -18,8 +17,16 @@ def test_backend_command_without_arguments(): "active_backend": "Gemini", "backends": { "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, - "Custom 1": {"api": "openai", "api_key_file": "~/.config/c1.token"}, - "Custom 2": {"api": "openai", "api_key_file": "~/.config/c2.token"}, + "Custom 1": { + "api": "openai", + "api_key_file": "~/.config/c1.token", + "default_model": "gpt-4o", + }, + "Custom 2": { + "api": "openai", + "api_key_file": "~/.config/c2.token", + "default_model": "gpt-4o-mini", + }, }, } @@ -98,6 +105,7 @@ def test_backend_command_switch_success(): assert util._BACKEND == "Custom 1" assert util._MODEL == "gpt-4o" assert util._API_KEY_FILE == os.path.expanduser("~/.config/proxy.token") + assert util._GENAI_CLIENT is None mock_send_setup.assert_called_once() mock_display.assert_called_once() assert ( @@ -118,6 +126,7 @@ def test_backend_command_switch_resets_key_and_model_when_missing(): "Local": { "api": "openai", "base_url": "http://localhost:11434/v1", + "default_model": "llama3", }, }, } @@ -126,6 +135,7 @@ def test_backend_command_switch_resets_key_and_model_when_missing(): util._MODEL = "gemini-3.8-flash" util._API_KEY_FILE = os.path.expanduser("~/.config/gemini.token") util._MODEL_NAME = "Cached Model Name" + util._GENAI_CLIENT = object() with ( patch("vimini.common.config.load_global_config", return_value=config), @@ -137,9 +147,10 @@ def test_backend_command_switch_resets_key_and_model_when_missing(): mock_set_active.assert_called_once_with("Local") assert util._BACKEND == "Local" - assert util._MODEL == DEFAULT_MODEL + assert util._MODEL == "llama3" assert util._MODEL_NAME is None assert util._API_KEY_FILE is None + assert util._GENAI_CLIENT is None mock_send_setup.assert_called_once() mock_display.assert_called_once() @@ -167,8 +178,16 @@ def test_get_available_backends(): "active_backend": "Gemini", "backends": { "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, - "Custom 1": {"api": "openai", "api_key_file": "~/.config/c1.token"}, - "Custom 2": {"api": "openai", "api_key_file": "~/.config/c2.token"}, + "Custom 1": { + "api": "openai", + "api_key_file": "~/.config/c1.token", + "default_model": "gpt-4o", + }, + "Custom 2": { + "api": "openai", + "api_key_file": "~/.config/c2.token", + "default_model": "gpt-4o-mini", + }, }, } @@ -301,6 +320,9 @@ def test_initialize_does_not_warn_for_keyless_openai_backend(): }, } + util._API_KEY_FILE = os.path.expanduser("~/.config/gemini.token") + util._GENAI_CLIENT = object() + with ( patch("vimini.common.config.load_global_config", return_value=config), patch("vimini.context.restore_context_files"), @@ -311,6 +333,8 @@ def test_initialize_does_not_warn_for_keyless_openai_backend(): assert util._BACKEND == "Local" assert util._MODEL == "local-model" + assert util._API_KEY_FILE is None + assert util._GENAI_CLIENT is None mock_display.assert_not_called()