Commit 7398a9223c6e

Vincent Demeester <vincent@sbr.pm>
2026-01-30 21:40:17
refactor(aichat): use REST API for Gemini instead of SDK
Replace google-generativeai SDK with direct REST API calls: - Removes grpc dependency (no native extensions needed) - Works without nix-ld on any NixOS system - Dependencies: requests, PyYAML (was: +google-generativeai, grpc, etc.) - Packages installed: 6 (was: 32) - Faster startup, simpler environment The Gemini REST API at generativelanguage.googleapis.com/v1beta/models provides the same model listing functionality. Also cleaned up code structure with better separation of concerns. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8dc8550
Changed files (1)
dots
.config
dots/.config/aichat/genconf.py
@@ -3,9 +3,13 @@
 # dependencies = [
 #     "requests",
 #     "PyYAML",
-#     "google-generativeai",
 # ]
 # ///
+#
+# Generate aichat config.yaml from config.yaml.in template.
+# - Resolves passage:: secrets
+# - Fetches available models from running services (Ollama, Gemini, etc.)
+# - Uses REST API for Gemini (no grpc/native extensions needed)
 
 import os
 import os.path
@@ -15,17 +19,16 @@ import sys
 from typing import Any, Dict, List, Optional
 
 import requests
-import yaml  # pip install pyyaml types-pyyaml
+import yaml
 import urllib.parse as urlparse
-import google.generativeai as genai
 
 
 def debug(msg: str):
     print(f"[DEBUG] {msg}", file=sys.stderr)
 
 
-def check_running(api_base, timeout=0.5) -> bool:
-    """Quickly check if Ollama is accessible at the given host and port."""
+def check_running(api_base: str, timeout: float = 0.5) -> bool:
+    """Quickly check if a service is accessible at the given host and port."""
     url = urlparse.urlparse(api_base)
     port = url.port or (80 if url.scheme == "http" else 443)
     debug(f"Checking if {url.hostname}:{port} is running (api_base={api_base})")
@@ -38,7 +41,7 @@ def check_running(api_base, timeout=0.5) -> bool:
         return result == 0
     except Exception as e:
         debug(f"Exception in check_running: {e}")
-        raise e
+        return False
 
 
 def load_config(config_path: str) -> Dict[str, Any]:
@@ -49,105 +52,15 @@ def load_config(config_path: str) -> Dict[str, Any]:
         return config
 
 
-def get_models(api_base: str, api_key_config: str | None) -> List[Dict[str, str]]:
-    """Query the models endpoint and return a list of model data"""
-    debug(
-        f"get_models called with api_base={api_base}, api_key_config={api_key_config}"
-    )
-    actual_api_key = None
-    if api_key_config:
-        if api_key_config.startswith("passage::"):
-            passage_path = api_key_config.split("::", 1)[1]
-            debug(f"Retrieving API key from passage at {passage_path}")
-            actual_api_key = get_passageword(passage_path)
-            if not actual_api_key:
-                print(
-                    f"Could not retrieve API key from passage for path: {passage_path}",
-                    file=sys.stderr,
-                )
-                debug(f"Failed to retrieve API key from passage for {passage_path}")
-                # Decide how to handle failure: skip, return empty, etc.
-                # Here we'll proceed without a key, which might fail later.
-        else:
-            actual_api_key = api_key_config  # Use the key directly if not a passage path
-            debug("Using API key directly from config")
-
-    headers = {}
-    if actual_api_key:
-        headers["Authorization"] = f"Bearer {actual_api_key}"
-        debug("Authorization header set")
-
-    # Ensure the URL is properly formatted
-    if not api_base.endswith("/"):
-        api_base = api_base + "/"
-        debug(f"api_base adjusted to {api_base}")
-
-    models_url = f"{api_base}models"
-    debug(f"Querying models endpoint: {models_url}")
-
-    try:
-        response = requests.get(models_url, headers=headers, timeout=10)
-        debug(f"HTTP GET {models_url} status_code={response.status_code}")
-        response.raise_for_status()
-        data = response.json()
-        debug(f"Response JSON: {data}")
-
-        # Extract models from response
-        models = data.get("data", [])
-        debug(f"Extracted models: {models}")
-        return [
-            {"name": model.get("id"), "description": model.get("id")}
-            for model in models
-        ]
-    except Exception as e:
-        print(f"Error querying {api_base}: {str(e)}", file=sys.stderr)
-        debug(f"Exception in get_models: {e}")
-        return []
-
-
-def get_gemini_models(api_key: Optional[str]) -> List[Dict[str, str]]:
-    """Query Google's Gemini API and return a list of available models"""
-    debug(f"get_gemini_models called with api_key={'***' if api_key else None}")
-    if not api_key:
-        print("Error: API key is required for Google Gemini API", file=sys.stderr)
-        debug("No API key provided to get_gemini_models")
-        return []
-
-    try:
-        # Configure the Gemini API with the provided key
-        debug("Configuring genai with provided API key")
-        genai.configure(api_key=api_key)
-
-        # Get list of available models
-        debug("Listing models from genai")
-        models_list = genai.list_models()
-        debug(f"Models list: {models_list}")
-
-        # Filter for Gemini models
-        gemini_models = [
-            {"name": model.name.split("/")[-1], "description": model.description}
-            for model in models_list
-            if "gemini" in model.name.lower()
-        ]
-        debug(f"Filtered Gemini models: {gemini_models}")
-
-        return gemini_models
-    except Exception as e:
-        print(f"Error querying Google Gemini API: {str(e)}", file=sys.stderr)
-        debug(f"Exception in get_gemini_models: {e}")
-        return []
-
-
-def get_passageword(passage_path: str) -> str | None:
-    """Retrieve passageword from passage using the given path."""
+def get_passageword(passage_path: str) -> Optional[str]:
+    """Retrieve password from passage using the given path."""
     debug(f"get_passageword called for passage_path={passage_path}")
     try:
         result = subprocess.run(
             ["passage", "show", passage_path], capture_output=True, text=True, check=True
         )
-        # Return the first line of the output, stripping newline
         passageword = result.stdout.splitlines()[0]
-        debug(f"Passageword retrieved from passage for {passage_path}")
+        debug(f"Password retrieved from passage for {passage_path}")
         return passageword
     except FileNotFoundError:
         print(
@@ -166,6 +79,128 @@ def get_passageword(passage_path: str) -> str | None:
         return None
 
 
+def resolve_api_key(api_key_config: Optional[str]) -> Optional[str]:
+    """Resolve API key, fetching from passage if needed."""
+    if not api_key_config:
+        return None
+    if api_key_config.startswith("passage::"):
+        passage_path = api_key_config.split("::", 1)[1]
+        debug(f"Retrieving API key from passage at {passage_path}")
+        return get_passageword(passage_path)
+    return api_key_config
+
+
+def get_models_openai(api_base: str, api_key: Optional[str]) -> List[Dict[str, str]]:
+    """Query OpenAI-compatible /models endpoint."""
+    debug(f"get_models_openai: api_base={api_base}")
+
+    headers = {}
+    if api_key:
+        headers["Authorization"] = f"Bearer {api_key}"
+
+    if not api_base.endswith("/"):
+        api_base = api_base + "/"
+
+    models_url = f"{api_base}models"
+    debug(f"Querying models endpoint: {models_url}")
+
+    try:
+        response = requests.get(models_url, headers=headers, timeout=10)
+        debug(f"HTTP GET {models_url} status_code={response.status_code}")
+        response.raise_for_status()
+        data = response.json()
+
+        models = data.get("data", [])
+        debug(f"Found {len(models)} models")
+        return [
+            {"name": model.get("id"), "description": model.get("id")}
+            for model in models
+        ]
+    except Exception as e:
+        print(f"Error querying {api_base}: {str(e)}", file=sys.stderr)
+        debug(f"Exception in get_models_openai: {e}")
+        return []
+
+
+def get_gemini_models(api_key: Optional[str]) -> List[Dict[str, str]]:
+    """Query Google's Gemini API via REST and return available models.
+
+    Uses the REST API directly instead of the google-generativeai SDK
+    to avoid grpc/native extension dependencies.
+    """
+    debug(f"get_gemini_models called with api_key={'***' if api_key else None}")
+    if not api_key:
+        print("Error: API key is required for Google Gemini API", file=sys.stderr)
+        return []
+
+    # Gemini REST API endpoint for listing models
+    url = "https://generativelanguage.googleapis.com/v1beta/models"
+    headers = {"x-goog-api-key": api_key}
+
+    try:
+        debug(f"Querying Gemini REST API: {url}")
+        response = requests.get(url, headers=headers, timeout=10)
+        debug(f"HTTP GET {url} status_code={response.status_code}")
+        response.raise_for_status()
+        data = response.json()
+
+        # Filter for gemini models and extract name/description
+        models = data.get("models", [])
+        gemini_models = [
+            {
+                "name": model.get("name", "").split("/")[-1],  # "models/gemini-pro" -> "gemini-pro"
+                "description": model.get("description", model.get("displayName", "")),
+            }
+            for model in models
+            if "gemini" in model.get("name", "").lower()
+        ]
+        debug(f"Found {len(gemini_models)} Gemini models")
+        return gemini_models
+
+    except requests.exceptions.HTTPError as e:
+        print(f"HTTP error querying Gemini API: {e}", file=sys.stderr)
+        debug(f"HTTPError in get_gemini_models: {e}")
+        return []
+    except Exception as e:
+        print(f"Error querying Google Gemini API: {str(e)}", file=sys.stderr)
+        debug(f"Exception in get_gemini_models: {e}")
+        return []
+
+
+def process_client(client: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+    """Process a single client configuration."""
+    updated_client = client.copy()
+    debug(f"Processing client: {updated_client.get('name', 'unnamed')}")
+
+    # Resolve API key from passage if needed
+    api_key_config = updated_client.get("api_key")
+    actual_api_key = resolve_api_key(api_key_config)
+    updated_client["api_key"] = actual_api_key
+
+    client_type = updated_client.get("type")
+
+    # OpenAI-compatible clients (Ollama, Groq, etc.)
+    if client_type == "openai-compatible":
+        if not updated_client.get("models"):
+            api_base = updated_client.get("api_base")
+            if api_base:
+                if not check_running(api_base):
+                    debug(f"{api_base} not running, skipping client")
+                    return None
+                fetched_models = get_models_openai(api_base, actual_api_key)
+                updated_client["models"] = fetched_models if fetched_models else []
+            else:
+                updated_client["models"] = []
+
+    # Google Gemini clients
+    elif client_type == "gemini":
+        if not updated_client.get("models"):
+            fetched_models = get_gemini_models(actual_api_key)
+            updated_client["models"] = fetched_models if fetched_models else []
+
+    return updated_client
+
+
 def main():
     # Support reading from script directory or ~/.config/aichat/
     script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -180,75 +215,16 @@ def main():
     if "clients" in config_data:
         updated_clients = []
         debug(f"main: found {len(config_data.get('clients', []))} clients")
+
         for client in config_data.get("clients", []):
-            # Make a copy to avoid modifying the original dict during iteration if needed elsewhere
-            updated_client = client.copy()
-            debug(f"Processing client: {updated_client}")
+            processed = process_client(client)
+            if processed is not None:
+                updated_clients.append(processed)
+                debug(f"main: client {client.get('name', 'unnamed')} processed")
 
-            actual_api_key = None
-            api_key_config = updated_client.get("api_key")
-            if api_key_config and api_key_config.startswith("passage::"):
-                passage_path = api_key_config.split("::", 1)[1]
-                debug(f"main: retrieving api_key from passage for {passage_path}")
-                actual_api_key = get_passageword(passage_path)
-            else:
-                actual_api_key = api_key_config
-            updated_client["api_key"] = actual_api_key
-            debug("main: actual_api_key set for client")
-
-            # For OpenAI-compatible clients, query and potentially add models
-            if updated_client.get("type") == "openai-compatible":
-                # Check if models are NOT already defined in config or are empty
-                if not updated_client.get("models"):
-                    api_base = updated_client.get("api_base")
-                    api_key = updated_client.get("api_key")
-                    debug(
-                        f"main: openai-compatible client, api_base={api_base}, api_key={'***' if api_key else None}"
-                    )
-
-                    # Skip ollama explicitly if needed, or handle based on your logic
-                    if api_base:
-                        if not check_running(api_base):
-                            debug(f"main: {api_base} not running, skipping client")
-                            continue
-                        # Try to fetch models from API
-                        fetched_models = get_models(api_base, api_key)
-                        if fetched_models:
-                            updated_client["models"] = fetched_models
-                            debug("main: models fetched and set for client")
-                        else:
-                            # Keep models empty/undefined or add an empty list
-                            updated_client["models"] = []
-                            debug("main: no models fetched, set empty list")
-                    else:
-                        # Handle cases where type is openai-compatible but no api_base
-                        updated_client["models"] = []
-                        debug(
-                            "main: openai-compatible client with no api_base, set empty models"
-                        )
-
-            # For Google Gemini clients, query and potentially add models
-            elif updated_client.get("type") == "gemini":
-                # Check if models are NOT already defined in config or are empty
-                if not updated_client.get("models"):
-                    api_key = updated_client.get("api_key")
-                    debug(f"main: gemini client, api_key={'***' if api_key else None}")
-                    fetched_models = get_gemini_models(api_key)
-                    if fetched_models:
-                        updated_client["models"] = fetched_models
-                        debug("main: gemini models fetched and set for client")
-                    else:
-                        updated_client["models"] = []
-                        debug("main: no gemini models fetched, set empty list")
-
-            updated_clients.append(updated_client)
-            debug("main: client processed and added to updated_clients")
-
-        # Replace the original clients list with the updated one
         config_data["clients"] = updated_clients
-        debug("main: updated_clients set in config_data")
 
-    # Print the entire (potentially updated) configuration as YAML
+    # Output final configuration as YAML
     debug("main: dumping config_data as YAML")
     print(yaml.dump(config_data, default_flow_style=False, sort_keys=False))