[agents] Offer Gemini as a model option alongside OpenRouter
This commit is contained in:
@ -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": ""})
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ VROBBLER_TODOIST_CLIENT_ID="<id>"
|
||||
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
||||
VROBBLER_GOOGLE_API_KEY="<key>"
|
||||
VROBBLER_GOOGLE_AI_API_KEY="<key>"
|
||||
VROBBLER_GEMINI_AGENT_MODELS="gemini-3.6-flash"
|
||||
VROBBLER_OPENROUTER_API_KEY="<key>"
|
||||
VROBBLER_LICHESS_API_KEY = "<key>"
|
||||
VROBBLER_FASTCORK_API_KEY="fc_<key>"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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", "")
|
||||
|
||||
@ -28,10 +28,16 @@
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<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>
|
||||
<form method="post" action="{% url 'agents:agent_scrobble_from_model' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="provider" value="{{ model.provider }}">
|
||||
<input type="hidden" name="model" value="{{ model.id }}">
|
||||
<input type="hidden" name="prompt" value="{{ prompt }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">Ask</button>
|
||||
|
||||
Reference in New Issue
Block a user