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-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. diff --git a/README.md b/README.md index 9f9d9a0..fc286e3 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 @@ -156,14 +156,90 @@ 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": [] + } + ``` + + You can configure multiple backends (Google Gemini via `"api": "genai"`, or OpenAI-compatible proxies and endpoints via `"api": "openai"`). + + 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` + +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. + ```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` 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 6525228..638070c 100644 --- a/plugin/vimini.vim +++ b/plugin/vimini.vim @@ -9,8 +9,8 @@ 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', '') " Configuration: How to split new windows ('vertical' or 'horizontal'). let g:vimini_split_method = get(g:, 'vimini_split_method', 'vertical') @@ -60,9 +60,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, 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 +106,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 @@ -463,6 +487,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/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..df8c766 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 = {} @@ -61,42 +61,23 @@ 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) - 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 +102,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/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" diff --git a/python3/vimini/common/backends/__init__.py b/python3/vimini/common/backends/__init__.py new file mode 100644 index 0000000..c6838e2 --- /dev/null +++ b/python3/vimini/common/backends/__init__.py @@ -0,0 +1,130 @@ +from typing import Any, Dict, Optional + +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 ( + 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, + validate_global_config, + backend_requires_api_key, +) + + +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 isinstance(config, dict) and "backends" in config: + validate_global_config(config) + global_cfg = config + if backend_name is None: + backend_name = ( + config.get("active_backend") or config.get("backend") or "Gemini" + ) + else: + 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: + available = list(backends.keys()) + raise ValueError( + f"Backend '{backend_name}' not found in configuration. Available: {available}" + ) + + backend_cfg = dict(backends[backend_name]) + api = backend_cfg.get("api", "genai") + + if api_key is None: + api_key = load_backend_api_key(backend_cfg) + + 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 " + f"api_key_file '{key_file}'" + ) + + if api == "genai": + return GenAIBackend( + api_key=api_key, + api_key_file=backend_cfg.get("api_key_file"), + config=backend_cfg, + ) + elif api == "openai": + 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}'.") + + +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", + "ProxyChatSession", + "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 new file mode 100644 index 0000000..3e1ea99 --- /dev/null +++ b/python3/vimini/common/backends/base.py @@ -0,0 +1,174 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +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 + 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, + verbose: bool = False, + response_schema: Optional[Any] = 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 + + 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 new file mode 100644 index 0000000..fe3dfcf --- /dev/null +++ b/python3/vimini/common/backends/genai_backend.py @@ -0,0 +1,352 @@ +import logging +from typing import Any, Dict, Iterator, List, Optional +from google.genai import types +from vimini.common.backends.base import ( + BaseChatSession, + BaseClient, + FunctionDeclaration, + Schema, + StreamChunk, + Tool, + ToolCall, + ToolResponse, +) +from vimini.common.genai import ( + get_client, + load_api_key, + create_generation_config, +) + +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, + 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, + call_id=getattr(fc, "id", None), + ) + ) + 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, + verbose: bool = False, + response_schema: Optional[Any] = None, + config: Optional[Any] = None, + ) -> Iterator[StreamChunk]: + if config is None: + 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 + ) + 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: + genai_tools = _to_genai_tools(tools) + config = create_generation_config( + tools=genai_tools, + verbose=verbose, + system_instruction=system_instruction, + 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 new file mode 100644 index 0000000..d83cceb --- /dev/null +++ b/python3/vimini/common/backends/proxy_backend.py @@ -0,0 +1,550 @@ +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 [] + + @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 or incoming == current: + return incoming + if incoming.startswith(current): + return incoming + return current + incoming + + @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"] = 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", "" + ) + 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]: + 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"), + "type": tc.get("type") or "function", + "function": {"name": "", "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) + + # 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}" + 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=fn_info.get("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"] = [] + 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_{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) + 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 = "" + 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 + 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() + 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: Any, + 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]] = [] + 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_str}) + + 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, + verbose: bool = False, + response_schema: Optional[Any] = None, + config: Optional[Any] = None, + ) -> Iterator[StreamChunk]: + messages: List[Dict[str, Any]] = [] + if system_instruction: + messages.append({"role": "system", "content": system_instruction}) + + 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 = { + "model": model, + "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", []) + 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/python3/vimini/common/config.py b/python3/vimini/common/config.py new file mode 100644 index 0000000..5b273c8 --- /dev/null +++ b/python3/vimini/common/config.py @@ -0,0 +1,250 @@ +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" +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 + 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" or bool(backend_config.get("api_key_file")) + 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. + """ + 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") + 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}" + ) + 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 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'" + ) + 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 + + +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/python3/vimini/config.py b/python3/vimini/config.py index e3e21aa..801e47c 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,422 @@ 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 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: + 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 + 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( + 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 +1307,116 @@ 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 + + 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})" + ) + 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 + ): + _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 013d5b7..5b4774f 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 @@ -23,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, @@ -32,20 +31,78 @@ ) -def initialize(api_key_file, model, logfile=None): +def _get_backend_default_model(backend_name, backend_cfg, default_model): """ - Initializes the plugin with the user's API key file path, model name, and - optional logfile path. + 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. + 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 ( + 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: + backend = get_active_backend(cfg) + util._BACKEND = backend + active_b_cfg = cfg.get("backends", {}).get(backend, {}) + 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 + 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(): + if config_error is not None: + util.display_message( + f"Global configuration is invalid: {config_error}", error=True + ) + 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 ) @@ -92,18 +149,140 @@ 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": { - "api_key_file": util._API_KEY_FILE, + "backend": backend_name, "model": util._MODEL, }, } 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, DEFAULT_MODEL + + 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}") + + target_cfg = backends[backend_name] + default_model = _get_backend_default_model(backend_name, target_cfg, DEFAULT_MODEL) + + if persist: + set_active_backend(backend_name) + + util._BACKEND = backend_name + try: + vim.command(f"let g:vimini_backend = {json.dumps(backend_name)}") + except Exception: + pass + + 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: + pass + util._API_KEY_FILE = ( + os.path.expanduser(target_cfg["api_key_file"]) + if target_cfg.get("api_key_file") + else None + ) + send_setup() + + +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, + ) + + 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", {}) + + 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( + f"{active} (available: {', '.join(others)})", history=True + ) + else: + util.display_message( + f"{active} (no other backends configured)", history=True + ) + return + + 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: + 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 + 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. @@ -208,7 +387,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: @@ -220,7 +398,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": @@ -234,7 +411,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 @@ -269,8 +446,12 @@ 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.", + " 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.", @@ -294,6 +475,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/python3/vimini/models.py b/python3/vimini/models.py index 2b39b68..e061e08 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.", - "", + "| : switch model | D: set default | q: close", ] + 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) @@ -249,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: @@ -266,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: @@ -273,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: - 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}'") @@ -306,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/python3/vimini/util.py b/python3/vimini/util.py index 9292297..86f5c97 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, @@ -12,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. @@ -224,6 +223,23 @@ 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 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() if not client: return _MODEL @@ -846,8 +862,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_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" 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 new file mode 100644 index 0000000..b37c12c --- /dev/null +++ b/tests/test_backend_abstraction.py @@ -0,0 +1,341 @@ +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.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() + + +def test_get_backend_defaults_to_active(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.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", "api_key_file": "~/.config/gemini.token"} + }, + } + 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", + "api_key_file": "~/.config/custom.token", + } + }, + } + with pytest.raises(ValueError, match="api 'unknown_api' is not supported"): + get_backend("Custom", config=config) + + +def test_get_backend_openai(): + config = { + "active_backend": "Custom 1", + "backends": { + "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"): + 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", + "requires_api_key": True, + "api_key_file": "~/.config/missing.token", + "default_model": "gpt-compatible", + } + }, + } + + 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_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", + "default_model": "gpt-compatible", + } + }, + } + + 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", + "backends": { + "Local": { + "api": "openai", + "base_url": "http://localhost:11434/v1", + "default_model": "llama3", + } + }, + } + + 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( + "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() + 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_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 new file mode 100644 index 0000000..04a2c6c --- /dev/null +++ b/tests/test_global_config.py @@ -0,0 +1,331 @@ +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, + backend_requires_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 '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( + { + "active_backend": "Gemini", + "backends": {"Gemini": {"api": "genai"}}, + } + ) + + validate_global_config( + { + "active_backend": "Local", + "backends": { + "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( + { + "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( + { + "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", + "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) + 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", "api_key_file": "~/.config/gemini.token"}, + "Custom 1": { + "api": "openai", + "api_key_file": "~/.config/custom.token", + "default_model": "gpt-compatible", + }, + }, + } + 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 + + +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_global_config_ui.py b/tests/test_global_config_ui.py new file mode 100644 index 0000000..876859c --- /dev/null +++ b/tests/test_global_config_ui.py @@ -0,0 +1,270 @@ +import copy +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 + ) + + +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") diff --git a/tests/test_models.py b/tests/test_models.py index 9e42b13..0cd6bcf 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 @@ -11,12 +12,29 @@ categorize_model, build_models_buffer, select_model, + set_default_model, SUPPORTED_CATEGORY, _LINE_TO_MODEL, ) import vimini.models as models_module from vimini import util +GENAI_GLOBAL_CONFIG = { + "active_backend": "Gemini", + "backends": { + "Gemini": {"api": "genai", "api_key_file": "~/.config/gemini.token"}, + }, +} + + +@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 +181,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 +207,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 +239,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"}, @@ -256,3 +274,53 @@ def test_select_unsupported_model_emits_error(): 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", + "api_key_file": "~/.config/gemini.token", + "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_proxy_backend.py b/tests/test_proxy_backend.py new file mode 100644 index 0000000..456e858 --- /dev/null +++ b/tests/test_proxy_backend.py @@ -0,0 +1,610 @@ +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_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( + 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( + 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_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_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") + assert name == "read" + assert merge(name, "") == "read" + name = merge(name, "_file") + assert name == "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" + + +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", + "api_key_file": "~/.config/proxyapi.token", + "default_model": "gpt-4o", + } + }, + } + + 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_util_buffer.py b/tests/test_util_buffer.py index 5126376..d215a50 100644 --- a/tests/test_util_buffer.py +++ b/tests/test_util_buffer.py @@ -190,3 +190,84 @@ 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" + + 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" + 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 new file mode 100644 index 0000000..5a2162d --- /dev/null +++ b/tests/test_vim_backend_ui.py @@ -0,0 +1,391 @@ +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", "api_key_file": "~/.config/gemini.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", + }, + }, + } + + 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", "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_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", + "backends": { + "Gemini": { + "api": "genai", + "api_key_file": "~/.config/gemini.token", + "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") + assert util._GENAI_CLIENT is None + 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_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", + "default_model": "llama3", + }, + }, + } + + 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" + util._GENAI_CLIENT = object() + + with ( + patch("vimini.common.config.load_global_config", return_value=config), + patch("vimini.common.config.set_active_backend") as mock_set_active, + patch("vimini.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 == "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() + + +def test_backend_command_switch_invalid(): + 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("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", "api_key_file": "~/.config/gemini.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", + }, + }, + } + + 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) + + +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") + + +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, + ) + + +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", + } + }, + } + + 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"), + 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" + assert util._API_KEY_FILE is None + assert util._GENAI_CLIENT is None + 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, + ) + + +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, + )