[agents] Offer Gemini as a model option alongside OpenRouter
Some checks failed
ci / test (push) Has been cancelled
ci / build-and-deploy (push) Has been cancelled

This commit is contained in:
2026-07-31 23:52:54 -04:00
parent 1e338dec2d
commit 42ccdf5507
6 changed files with 117 additions and 22 deletions

View File

@ -8,6 +8,7 @@ from agents.providers import (
_parse_opencode_events, _parse_opencode_events,
agent_prompt, agent_prompt,
gemini_agent_prompt, gemini_agent_prompt,
list_gemini_agent_models,
list_openrouter_free_models, list_openrouter_free_models,
opencode_agent_prompt, opencode_agent_prompt,
openrouter_agent_prompt, openrouter_agent_prompt,
@ -315,8 +316,8 @@ def test_list_openrouter_free_models_filters_free():
mock_get.assert_called_once() mock_get.assert_called_once()
assert models == [ assert models == [
{"id": "free/model-b", "name": "Alpha"}, {"provider": "openrouter", "id": "free/model-b", "name": "Alpha"},
{"id": "free/model-a", "name": "Bee"}, {"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() == [] 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 --- # --- 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") @patch("agents.views.list_openrouter_free_models")
def test_agent_model_select_view_lists_models(mock_models, client, user): @patch("agents.views.list_gemini_agent_models")
mock_models.return_value = [ def test_agent_model_select_view_lists_models(mock_gemini, mock_or, client, user):
{"id": "free/model-a", "name": "Model A"}, mock_gemini.return_value = [
{"id": "free/model-b", "name": "Model B"}, {"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) client.force_login(user)
session = client.session 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"what is 2+2?" in response.content
assert b"free/model-a" in response.content assert b"free/model-a" in response.content
assert b"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") @patch("agents.views.list_openrouter_free_models")
def test_agent_model_select_view_handles_errors(mock_models, client, user): @patch("agents.views.list_gemini_agent_models")
mock_models.side_effect = ValueError("boom") 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) client.force_login(user)
session = client.session session = client.session
session["agent_prompt"] = "hi" 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 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") @patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_agent_scrobble_from_model_creates_session( def test_agent_scrobble_from_model_creates_session(mock_delay, client, user):
mock_delay, mock_models, client, user
):
client.force_login(user) client.force_login(user)
session = client.session session = client.session
session["agent_prompt"] = "what is 2+2?" 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 assert "agent_prompt" not in client.session
@patch("agents.views.list_openrouter_free_models")
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay") @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) client.force_login(user)
response = client.post(reverse("agents:agent_scrobble_from_model"), {"model": ""}) response = client.post(reverse("agents:agent_scrobble_from_model"), {"model": ""})

View File

@ -25,6 +25,7 @@ VROBBLER_TODOIST_CLIENT_ID="<id>"
VROBBLER_TODOIST_CLIENT_SECRET="<key>" VROBBLER_TODOIST_CLIENT_SECRET="<key>"
VROBBLER_GOOGLE_API_KEY="<key>" VROBBLER_GOOGLE_API_KEY="<key>"
VROBBLER_GOOGLE_AI_API_KEY="<key>" VROBBLER_GOOGLE_AI_API_KEY="<key>"
VROBBLER_GEMINI_AGENT_MODELS="gemini-3.6-flash"
VROBBLER_OPENROUTER_API_KEY="<key>" VROBBLER_OPENROUTER_API_KEY="<key>"
VROBBLER_LICHESS_API_KEY = "<key>" VROBBLER_LICHESS_API_KEY = "<key>"
VROBBLER_FASTCORK_API_KEY="fc_<key>" VROBBLER_FASTCORK_API_KEY="fc_<key>"

View File

@ -174,7 +174,8 @@ def list_openrouter_free_models() -> list[dict]:
"""Fetch OpenRouter's model catalog and return the free models. """Fetch OpenRouter's model catalog and return the free models.
A model is considered free when both prompt and completion pricing are $0. 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 = httpx.get(OPENROUTER_MODELS_URL, timeout=30.0)
response.raise_for_status() response.raise_for_status()
@ -190,6 +191,36 @@ def list_openrouter_free_models() -> list[dict]:
model_id = item.get("id", "") model_id = item.get("id", "")
if not model_id: if not model_id:
continue 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"])) models.sort(key=lambda m: (m["name"].lower(), m["id"]))
return models 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

View File

@ -1,7 +1,10 @@
import logging import logging
from agents.models import AgentSession 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 import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
@ -28,11 +31,16 @@ class AgentModelSelectView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["prompt"] = self.request.session.get("agent_prompt", "").strip() context["prompt"] = self.request.session.get("agent_prompt", "").strip()
models = []
try: 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: except Exception as e:
logger.exception("[agents] failed to load OpenRouter models") logger.exception("[agents] failed to load OpenRouter models")
models = []
context["models_error"] = f"Could not load models from OpenRouter: {e}" context["models_error"] = f"Could not load models from OpenRouter: {e}"
context["models"] = models context["models"] = models
if models: if models:
@ -45,6 +53,7 @@ class AgentScrobbleFromModelView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
model = request.POST.get("model", "").strip() model = request.POST.get("model", "").strip()
provider = request.POST.get("provider", "").strip() or "openrouter"
prompt = ( prompt = (
request.POST.get("prompt", "").strip() request.POST.get("prompt", "").strip()
or request.session.get("agent_prompt", "").strip() or request.session.get("agent_prompt", "").strip()
@ -58,7 +67,7 @@ class AgentScrobbleFromModelView(LoginRequiredMixin, View):
return HttpResponseRedirect(reverse("agents:agent_model_select")) return HttpResponseRedirect(reverse("agents:agent_model_select"))
scrobble = manual_scrobble_agent_session( scrobble = manual_scrobble_agent_session(
prompt, request.user.id, provider="openrouter", model=model prompt, request.user.id, provider=provider, model=model
) )
if scrobble: if scrobble:
request.session.pop("agent_prompt", None) request.session.pop("agent_prompt", None)

View File

@ -92,6 +92,7 @@ OPENROUTER_API_KEY = os.getenv("VROBBLER_OPENROUTER_API_KEY", "")
AGENT_PROVIDER = os.getenv("VROBBLER_AGENT_PROVIDER", "openrouter") AGENT_PROVIDER = os.getenv("VROBBLER_AGENT_PROVIDER", "openrouter")
LLM_MODEL = os.getenv("VROBBLER_LLM_MODEL", "openai/gpt-oss-20b:free") 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") OPENCODE_CMD = os.getenv("VROBBLER_OPENCODE_CMD", "opencode")
AMAZON_PAAPI_ACCESS_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_ACCESS_KEY", "") AMAZON_PAAPI_ACCESS_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_ACCESS_KEY", "")

View File

@ -28,10 +28,16 @@
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item d-flex justify-content-between align-items-center">
<div> <div>
<div class="fw-semibold">{{ model.name }}</div> <div class="fw-semibold">{{ model.name }}</div>
<div class="result-meta text-muted">{{ model.id }}</div> <div class="result-meta text-muted">
{% if model.provider == "gemini" %}
<span class="badge text-bg-light border me-1">Google Gemini</span>
{% endif %}
{{ model.id }}
</div>
</div> </div>
<form method="post" action="{% url 'agents:agent_scrobble_from_model' %}"> <form method="post" action="{% url 'agents:agent_scrobble_from_model' %}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="provider" value="{{ model.provider }}">
<input type="hidden" name="model" value="{{ model.id }}"> <input type="hidden" name="model" value="{{ model.id }}">
<input type="hidden" name="prompt" value="{{ prompt }}"> <input type="hidden" name="prompt" value="{{ prompt }}">
<button type="submit" class="btn btn-sm btn-outline-success">Ask</button> <button type="submit" class="btn btn-sm btn-outline-success">Ask</button>