[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,
|
_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": ""})
|
||||||
|
|
||||||
|
|||||||
@ -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>"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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", "")
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user