From 42ccdf550708a242d0723a6c73b76ac1bcf86eef Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Fri, 31 Jul 2026 23:52:54 -0400 Subject: [PATCH] [agents] Offer Gemini as a model option alongside OpenRouter --- tests/agents_tests/test_agent_sessions.py | 77 +++++++++++++++---- vrobbler.conf.example | 1 + vrobbler/apps/agents/providers.py | 35 ++++++++- vrobbler/apps/agents/views.py | 17 +++- vrobbler/settings.py | 1 + .../templates/agents/agent_model_select.html | 8 +- 6 files changed, 117 insertions(+), 22 deletions(-) diff --git a/tests/agents_tests/test_agent_sessions.py b/tests/agents_tests/test_agent_sessions.py index 05f6844..7d0f9aa 100644 --- a/tests/agents_tests/test_agent_sessions.py +++ b/tests/agents_tests/test_agent_sessions.py @@ -8,6 +8,7 @@ from agents.providers import ( _parse_opencode_events, agent_prompt, gemini_agent_prompt, + list_gemini_agent_models, list_openrouter_free_models, opencode_agent_prompt, openrouter_agent_prompt, @@ -315,8 +316,8 @@ def test_list_openrouter_free_models_filters_free(): mock_get.assert_called_once() assert models == [ - {"id": "free/model-b", "name": "Alpha"}, - {"id": "free/model-a", "name": "Bee"}, + {"provider": "openrouter", "id": "free/model-b", "name": "Alpha"}, + {"provider": "openrouter", "id": "free/model-a", "name": "Bee"}, ] @@ -332,6 +333,28 @@ def test_list_openrouter_free_models_skips_missing_pricing(): assert list_openrouter_free_models() == [] +def test_list_gemini_agent_models(settings): + settings.GOOGLE_AI_API_KEY = "test-key" + settings.GEMINI_AGENT_MODELS = "gemini-2.5-flash, gemini-3.6-flash" + assert list_gemini_agent_models() == [ + { + "provider": "gemini", + "id": "gemini-2.5-flash", + "name": "gemini-2.5-flash", + }, + { + "provider": "gemini", + "id": "gemini-3.6-flash", + "name": "gemini-3.6-flash", + }, + ] + + +def test_list_gemini_agent_models_no_key(settings): + settings.GOOGLE_AI_API_KEY = "" + assert list_gemini_agent_models() == [] + + # --- scrobbler --- @@ -580,10 +603,14 @@ def test_manual_scrobble_view_routes_plain_text_to_agent(mock_delay, client, use @patch("agents.views.list_openrouter_free_models") -def test_agent_model_select_view_lists_models(mock_models, client, user): - mock_models.return_value = [ - {"id": "free/model-a", "name": "Model A"}, - {"id": "free/model-b", "name": "Model B"}, +@patch("agents.views.list_gemini_agent_models") +def test_agent_model_select_view_lists_models(mock_gemini, mock_or, client, user): + mock_gemini.return_value = [ + {"provider": "gemini", "id": "gemini-3.6-flash", "name": "gemini-3.6-flash"} + ] + mock_or.return_value = [ + {"provider": "openrouter", "id": "free/model-a", "name": "Model A"}, + {"provider": "openrouter", "id": "free/model-b", "name": "Model B"}, ] client.force_login(user) session = client.session @@ -595,12 +622,17 @@ def test_agent_model_select_view_lists_models(mock_models, client, user): assert b"what is 2+2?" in response.content assert b"free/model-a" in response.content assert b"Model A" in response.content - assert client.session["agent_models"] == mock_models.return_value + assert b"gemini-3.6-flash" in response.content + assert b"Google Gemini" in response.content + expected = mock_gemini.return_value + mock_or.return_value + assert client.session["agent_models"] == expected @patch("agents.views.list_openrouter_free_models") -def test_agent_model_select_view_handles_errors(mock_models, client, user): - mock_models.side_effect = ValueError("boom") +@patch("agents.views.list_gemini_agent_models") +def test_agent_model_select_view_handles_errors(mock_gemini, mock_or, client, user): + mock_gemini.return_value = [] + mock_or.side_effect = ValueError("boom") client.force_login(user) session = client.session session["agent_prompt"] = "hi" @@ -611,11 +643,8 @@ def test_agent_model_select_view_handles_errors(mock_models, client, user): assert b"Could not load models from OpenRouter" in response.content -@patch("agents.views.list_openrouter_free_models") @patch("scrobbles.tasks.scrobble_agent_session_prompt.delay") -def test_agent_scrobble_from_model_creates_session( - mock_delay, mock_models, client, user -): +def test_agent_scrobble_from_model_creates_session(mock_delay, client, user): client.force_login(user) session = client.session session["agent_prompt"] = "what is 2+2?" @@ -635,9 +664,27 @@ def test_agent_scrobble_from_model_creates_session( assert "agent_prompt" not in client.session -@patch("agents.views.list_openrouter_free_models") @patch("scrobbles.tasks.scrobble_agent_session_prompt.delay") -def test_agent_scrobble_from_model_missing_model(mock_delay, mock_models, client, user): +def test_agent_scrobble_from_model_gemini_provider(mock_delay, client, user): + client.force_login(user) + session = client.session + session["agent_prompt"] = "what is 2+2?" + session.save() + response = client.post( + reverse("agents:agent_scrobble_from_model"), + {"provider": "gemini", "model": "gemini-3.6-flash", "prompt": "what is 2+2?"}, + ) + + assert response.status_code == 302 + scrobble = Scrobble.objects.filter(user_id=user.id).first() + assert scrobble is not None + assert scrobble.agent_session.provider == "gemini" + assert scrobble.agent_session.model == "gemini-3.6-flash" + assert response.url == scrobble.get_absolute_url() + + +@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay") +def test_agent_scrobble_from_model_missing_model(mock_delay, client, user): client.force_login(user) response = client.post(reverse("agents:agent_scrobble_from_model"), {"model": ""}) diff --git a/vrobbler.conf.example b/vrobbler.conf.example index 97537f8..49192ee 100644 --- a/vrobbler.conf.example +++ b/vrobbler.conf.example @@ -25,6 +25,7 @@ VROBBLER_TODOIST_CLIENT_ID="" VROBBLER_TODOIST_CLIENT_SECRET="" VROBBLER_GOOGLE_API_KEY="" VROBBLER_GOOGLE_AI_API_KEY="" +VROBBLER_GEMINI_AGENT_MODELS="gemini-3.6-flash" VROBBLER_OPENROUTER_API_KEY="" VROBBLER_LICHESS_API_KEY = "" VROBBLER_FASTCORK_API_KEY="fc_" diff --git a/vrobbler/apps/agents/providers.py b/vrobbler/apps/agents/providers.py index b04b602..1bf5eed 100644 --- a/vrobbler/apps/agents/providers.py +++ b/vrobbler/apps/agents/providers.py @@ -174,7 +174,8 @@ def list_openrouter_free_models() -> list[dict]: """Fetch OpenRouter's model catalog and return the free models. A model is considered free when both prompt and completion pricing are $0. - Returns a list of ``{"id": str, "name": str}`` dicts sorted by name. + Returns a list of ``{"provider": str, "id": str, "name": str}`` dicts sorted + by name. """ response = httpx.get(OPENROUTER_MODELS_URL, timeout=30.0) response.raise_for_status() @@ -190,6 +191,36 @@ def list_openrouter_free_models() -> list[dict]: model_id = item.get("id", "") if not model_id: continue - models.append({"id": model_id, "name": item.get("name") or model_id}) + models.append( + { + "provider": "openrouter", + "id": model_id, + "name": item.get("name") or model_id, + } + ) models.sort(key=lambda m: (m["name"].lower(), m["id"])) return models + + +def list_gemini_agent_models() -> list[dict]: + """Return the configured Gemini model options for agent sessions. + + Gemini calls do not go through OpenRouter, so the models are only offered + when ``VROBBLER_GOOGLE_AI_API_KEY`` is set. Returns a list of + ``{"provider": str, "id": str, "name": str}`` dicts. + """ + if not settings.GOOGLE_AI_API_KEY: + return [] + models = [] + for model_id in settings.GEMINI_AGENT_MODELS.split(","): + model_id = model_id.strip() + if not model_id: + continue + models.append( + { + "provider": "gemini", + "id": model_id, + "name": model_id, + } + ) + return models diff --git a/vrobbler/apps/agents/views.py b/vrobbler/apps/agents/views.py index 5980e94..2c41591 100644 --- a/vrobbler/apps/agents/views.py +++ b/vrobbler/apps/agents/views.py @@ -1,7 +1,10 @@ import logging from agents.models import AgentSession -from agents.providers import list_openrouter_free_models +from agents.providers import ( + list_gemini_agent_models, + list_openrouter_free_models, +) from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponseRedirect @@ -28,11 +31,16 @@ class AgentModelSelectView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["prompt"] = self.request.session.get("agent_prompt", "").strip() + models = [] try: - models = list_openrouter_free_models() + models.extend(list_gemini_agent_models()) + except Exception as e: + logger.exception("[agents] failed to load Gemini models") + context["models_error"] = f"Could not load Gemini models: {e}" + try: + models.extend(list_openrouter_free_models()) except Exception as e: logger.exception("[agents] failed to load OpenRouter models") - models = [] context["models_error"] = f"Could not load models from OpenRouter: {e}" context["models"] = models if models: @@ -45,6 +53,7 @@ class AgentScrobbleFromModelView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): model = request.POST.get("model", "").strip() + provider = request.POST.get("provider", "").strip() or "openrouter" prompt = ( request.POST.get("prompt", "").strip() or request.session.get("agent_prompt", "").strip() @@ -58,7 +67,7 @@ class AgentScrobbleFromModelView(LoginRequiredMixin, View): return HttpResponseRedirect(reverse("agents:agent_model_select")) scrobble = manual_scrobble_agent_session( - prompt, request.user.id, provider="openrouter", model=model + prompt, request.user.id, provider=provider, model=model ) if scrobble: request.session.pop("agent_prompt", None) diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 58de572..03b0c28 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -92,6 +92,7 @@ OPENROUTER_API_KEY = os.getenv("VROBBLER_OPENROUTER_API_KEY", "") AGENT_PROVIDER = os.getenv("VROBBLER_AGENT_PROVIDER", "openrouter") LLM_MODEL = os.getenv("VROBBLER_LLM_MODEL", "openai/gpt-oss-20b:free") +GEMINI_AGENT_MODELS = os.getenv("VROBBLER_GEMINI_AGENT_MODELS", "gemini-3.6-flash") OPENCODE_CMD = os.getenv("VROBBLER_OPENCODE_CMD", "opencode") AMAZON_PAAPI_ACCESS_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_ACCESS_KEY", "") diff --git a/vrobbler/templates/agents/agent_model_select.html b/vrobbler/templates/agents/agent_model_select.html index c2e59b1..da93108 100644 --- a/vrobbler/templates/agents/agent_model_select.html +++ b/vrobbler/templates/agents/agent_model_select.html @@ -28,10 +28,16 @@
{{ model.name }}
-
{{ model.id }}
+
+ {% if model.provider == "gemini" %} + Google Gemini + {% endif %} + {{ model.id }} +
{% csrf_token %} +