[agents] Swap agent flow to OpenRouter with model selection
All checks were successful
ci / test (push) Successful in 2m35s
ci / build-and-deploy (push) Has been skipped

This commit is contained in:
2026-07-31 23:06:09 -04:00
parent 25f83bf08d
commit 1e338dec2d
12 changed files with 454 additions and 31 deletions

View File

@ -185,7 +185,7 @@ events).
- Timestamp picker (default to now)
- Optional: barcode scanning for food/drinks via CameraX
* Backlog [1/26] :vrobbler:project:personal:
* Backlog [0/27] :vrobbler:project:personal:
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES:
@ -713,6 +713,16 @@ From that point on, the scrobbles is of that agent model and the response should
come from there, with any susequent prompts going to thes ame model (with
context of past prompts and response).
**** Tasks
- [X] Add openrouter_agent_prompt and list_openrouter_free_models to agents/providers.py
- [X] AgentModelSelectView lists free OpenRouter models with the pending prompt
- [X] AgentScrobbleFromModelView scrobbles the chosen model as an agent session
- [X] ManualScrobbleView routes unprefixed prompts to the model select page via session
- [X] scrobble_agent_session_prompt task passes the session model to the provider
- [X] Keep gemini and opencode provider routes working during the transition
- [X] Update settings, conf example and .envrc for OpenRouter
- [X] Write tests
** TODO [#B] Mood scrobble page with "Check-in complete!" should ask for context :moods:
*** Description
@ -721,6 +731,31 @@ Currently when scrobbling a mood, the finish page just says "complete!" Instead,
that page should be a simple form with a textarea in it with a request for
context. Saving that form should save the context as a note on the scrobble.
* Version 63.4 [1/1]
** DONE [#A] Update agent flow to use OpenRouter and select model :agents:
:PROPERTIES:
:ID: 70f753a8-b320-4f35-1dcd-9f0d4f1c707b
:END:
*** Description
Unprefixed prompts in the scrobble bar no longer hit Gemini directly. They now
redirect to an agent model select page (like Foods search) that lists free
OpenRouter models (priced $0 for both prompt and completion) pulled from the
OpenRouter models API. Picking one scrobbles that model as an agent session with
the prompt as the title; follow-ups keep using the same model with conversation
context. gemini and opencode provider routes remain for the transition.
**** Tasks
- [X] Add openrouter_agent_prompt and list_openrouter_free_models to agents/providers.py
- [X] AgentModelSelectView lists free OpenRouter models with the pending prompt
- [X] AgentScrobbleFromModelView scrobbles the chosen model as an agent session
- [X] ManualScrobbleView routes unprefixed prompts to the model select page via session
- [X] scrobble_agent_session_prompt task passes the session model to the provider
- [X] Keep gemini and opencode provider routes working during the transition
- [X] Update settings, conf example and .envrc for OpenRouter
- [X] Write tests
* Version 63.3 [1/1]
** DONE [#A] Fix overuse of OSM API on reverse geocodes :bug:geolocations:
:PROPERTIES:

View File

@ -8,7 +8,9 @@ from agents.providers import (
_parse_opencode_events,
agent_prompt,
gemini_agent_prompt,
list_openrouter_free_models,
opencode_agent_prompt,
openrouter_agent_prompt,
)
from django.conf import settings
from django.contrib.auth import get_user_model
@ -35,7 +37,7 @@ def user(db):
def _mk_scrobble(user, *, in_progress=True, turns=None, title="First prompt"):
agent_session, _ = AgentSession.find_or_create(
provider="gemini", model=settings.LLM_MODEL
provider=settings.AGENT_PROVIDER, model=settings.LLM_MODEL
)
scrobble = Scrobble.create_or_update(
agent_session,
@ -49,7 +51,7 @@ def _mk_scrobble(user, *, in_progress=True, turns=None, title="First prompt"):
skip_in_progress_check=True,
)
log = scrobble.log if isinstance(scrobble.log, dict) else {}
log["provider"] = "gemini"
log["provider"] = settings.AGENT_PROVIDER
log["model"] = settings.LLM_MODEL
log["title"] = title
log["turns"] = turns or []
@ -214,7 +216,120 @@ def test_agent_prompt_defaults_to_configured_provider(settings):
settings.GOOGLE_AI_API_KEY = "test-key"
with patch("agents.providers.gemini_agent_prompt", return_value={"text": "x"}) as m:
agent_prompt("hi")
m.assert_called_once_with("hi", history=None)
m.assert_called_once_with("hi", history=None, model=None)
def test_agent_prompt_openrouter_dispatches(settings):
settings.AGENT_PROVIDER = "openrouter"
settings.OPENROUTER_API_KEY = "sk-test"
with patch(
"agents.providers.openrouter_agent_prompt", return_value={"text": "x"}
) as m:
agent_prompt("hi", model="free/model-a")
m.assert_called_once_with("hi", history=None, model="free/model-a")
def test_openrouter_agent_prompt(settings):
settings.OPENROUTER_API_KEY = "sk-test"
settings.LLM_MODEL = "some/model"
fake = MagicMock()
fake.raise_for_status = MagicMock()
fake.json.return_value = {"choices": [{"message": {"content": " the answer "}}]}
with patch("agents.providers.httpx.post", return_value=fake) as mock_post:
result = openrouter_agent_prompt("what is 2+2?")
mock_post.assert_called_once()
kwargs = mock_post.call_args.kwargs
assert kwargs["headers"] == {"Authorization": "Bearer sk-test"}
assert kwargs["json"] == {
"model": "some/model",
"messages": [{"role": "user", "content": "what is 2+2?"}],
}
assert result == {
"text": "the answer",
"provider": "openrouter",
"model": "some/model",
}
def test_openrouter_agent_prompt_missing_key(settings):
settings.OPENROUTER_API_KEY = ""
with pytest.raises(ValueError, match="VROBBLER_OPENROUTER_API_KEY is not set"):
openrouter_agent_prompt("hi")
def test_openrouter_agent_prompt_bad_response(settings):
settings.OPENROUTER_API_KEY = "sk-test"
fake = MagicMock()
fake.raise_for_status = MagicMock()
fake.json.return_value = {"unexpected": True}
with patch("agents.providers.httpx.post", return_value=fake):
with pytest.raises(ValueError, match="Unexpected OpenRouter response"):
openrouter_agent_prompt("hi")
def test_openrouter_agent_prompt_with_history(settings):
settings.OPENROUTER_API_KEY = "sk-test"
fake = MagicMock()
fake.raise_for_status = MagicMock()
fake.json.return_value = {"choices": [{"message": {"content": "12"}}]}
history = [
{"prompt": "what is 2+2?", "response": "4"},
{"prompt": "no response yet", "response": None},
]
with patch("agents.providers.httpx.post", return_value=fake) as mock_post:
openrouter_agent_prompt("times 3?", history=history, model="some/model")
messages = mock_post.call_args.kwargs["json"]["messages"]
assert messages == [
{"role": "user", "content": "what is 2+2?"},
{"role": "assistant", "content": "4"},
{"role": "user", "content": "times 3?"},
]
def test_list_openrouter_free_models_filters_free():
fake = MagicMock()
fake.raise_for_status = MagicMock()
fake.json.return_value = {
"data": [
{
"id": "paid/model",
"name": "Paid",
"pricing": {"prompt": "0.001", "completion": "0.002"},
},
{
"id": "free/model-a",
"name": "Bee",
"pricing": {"prompt": "0", "completion": "0"},
},
{
"id": "free/model-b",
"name": "Alpha",
"pricing": {"prompt": "0", "completion": "0"},
},
]
}
with patch("agents.providers.httpx.get", return_value=fake) as mock_get:
models = list_openrouter_free_models()
mock_get.assert_called_once()
assert models == [
{"id": "free/model-b", "name": "Alpha"},
{"id": "free/model-a", "name": "Bee"},
]
def test_list_openrouter_free_models_skips_missing_pricing():
fake = MagicMock()
fake.raise_for_status = MagicMock()
fake.json.return_value = {
"data": [
{"id": "no-pricing/model", "name": "No Pricing"},
]
}
with patch("agents.providers.httpx.get", return_value=fake):
assert list_openrouter_free_models() == []
# --- scrobbler ---
@ -227,9 +342,12 @@ def test_manual_scrobble_agent_session_creates_new_session(mock_delay, user):
assert Scrobble.objects.filter(id=scrobble.id).exists()
assert scrobble.media_type == Scrobble.MediaType.AGENT_SESSION
assert scrobble.in_progress is True
assert scrobble.agent_session.provider == "gemini"
assert scrobble.agent_session.provider == settings.AGENT_PROVIDER
assert scrobble.agent_session.model == settings.LLM_MODEL
assert scrobble.agent_session.title == f"gemini {settings.LLM_MODEL}"
assert (
scrobble.agent_session.title
== f"{settings.AGENT_PROVIDER} {settings.LLM_MODEL}"
)
assert scrobble.log["title"] == "What is the capital of France?"
turns = scrobble.log["turns"]
assert len(turns) == 1
@ -241,14 +359,14 @@ def test_manual_scrobble_agent_session_creates_new_session(mock_delay, user):
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_session_reuses_agent_session(mock_delay, user):
agent_session, created = AgentSession.find_or_create(
provider="gemini", model=settings.LLM_MODEL
provider=settings.AGENT_PROVIDER, model=settings.LLM_MODEL
)
assert created is True
manual_scrobble_agent_session("First question?", user.id)
agent_session_again, created = AgentSession.find_or_create(
provider="gemini", model=settings.LLM_MODEL
provider=settings.AGENT_PROVIDER, model=settings.LLM_MODEL
)
assert created is False
assert agent_session_again.id == agent_session.id
@ -317,16 +435,18 @@ def test_scrobble_agent_session_prompt_fills_turn(
)
mock_agent_prompt.return_value = {
"text": "hi back",
"provider": "gemini",
"model": "gemini-2.5-flash",
"provider": settings.AGENT_PROVIDER,
"model": settings.LLM_MODEL,
}
scrobble_agent_session_prompt(scrobble.id, "abc-123")
scrobble.refresh_from_db()
mock_agent_prompt.assert_called_once_with("hello", provider="gemini", history=[])
mock_agent_prompt.assert_called_once_with(
"hello", provider=settings.AGENT_PROVIDER, history=[], model=settings.LLM_MODEL
)
assert scrobble.log["turns"][0]["response"] == "hi back"
assert scrobble.log["provider"] == "gemini"
assert scrobble.log["provider"] == settings.AGENT_PROVIDER
assert scrobble.in_progress is True
assert scrobble.played_to_completion is False
mock_complete.assert_called_once_with(
@ -363,15 +483,16 @@ def test_scrobble_agent_session_prompt_passes_history(
mock_agent_prompt.return_value = {
"text": "12",
"provider": "gemini",
"provider": settings.AGENT_PROVIDER,
"model": settings.LLM_MODEL,
}
scrobble_agent_session_prompt(scrobble.id, "cur-1")
mock_agent_prompt.assert_called_once_with(
"and times 3?",
provider="gemini",
provider=settings.AGENT_PROVIDER,
history=[{"prompt": "what is 2+2?", "response": "4"}],
model=settings.LLM_MODEL,
)
@ -452,11 +573,90 @@ def test_manual_scrobble_view_routes_plain_text_to_agent(mock_delay, client, use
{"item_id": "what is the meaning of life?"},
)
assert response.status_code == 302
assert response.url == reverse("agents:agent_model_select")
assert client.session["agent_prompt"] == "what is the meaning of life?"
assert not Scrobble.objects.filter(user_id=user.id).exists()
@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"},
]
client.force_login(user)
session = client.session
session["agent_prompt"] = "what is 2+2?"
session.save()
response = client.get(reverse("agents:agent_model_select"))
assert response.status_code == 200
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
@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")
client.force_login(user)
session = client.session
session["agent_prompt"] = "hi"
session.save()
response = client.get(reverse("agents:agent_model_select"))
assert response.status_code == 200
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
):
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"),
{"model": "free/model-a", "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.media_type == Scrobble.MediaType.AGENT_SESSION
assert response.url.startswith(reverse("scrobbles:detail", args=[scrobble.id]))
assert scrobble.agent_session.provider == "openrouter"
assert scrobble.agent_session.model == "free/model-a"
assert scrobble.log["title"] == "what is 2+2?"
assert response.url == scrobble.get_absolute_url()
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):
client.force_login(user)
response = client.post(reverse("agents:agent_scrobble_from_model"), {"model": ""})
assert response.status_code == 302
assert response.url == reverse("agents:agent_model_select")
mock_delay.assert_not_called()
assert not Scrobble.objects.filter(user_id=user.id).exists()
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_session_explicit_model(mock_delay, user):
scrobble = manual_scrobble_agent_session(
"hi", user.id, provider="openrouter", model="free/model-a"
)
assert scrobble.agent_session.provider == "openrouter"
assert scrobble.agent_session.model == "free/model-a"
assert scrobble.agent_session.title == "openrouter free/model-a"
assert scrobble.log["turns"][0]["prompt"] == "hi"
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")

View File

@ -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_OPENROUTER_API_KEY="<key>"
VROBBLER_LICHESS_API_KEY = "<key>"
VROBBLER_FASTCORK_API_KEY="fc_<key>"

View File

@ -12,11 +12,15 @@ GEMINI_GENERATE_CONTENT_URL = (
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
)
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
def agent_prompt(
prompt: str,
provider: str | None = None,
history: list[dict] | None = None,
model: str | None = None,
) -> dict:
"""Send a prompt to the configured agent provider and return the response.
@ -27,9 +31,11 @@ def agent_prompt(
"""
provider = provider or settings.AGENT_PROVIDER
if provider == "gemini":
return gemini_agent_prompt(prompt, history=history)
return gemini_agent_prompt(prompt, history=history, model=model)
if provider == "opencode":
return opencode_agent_prompt(prompt, history=history)
if provider == "openrouter":
return openrouter_agent_prompt(prompt, history=history, model=model)
raise ValueError(f"Unknown agent provider: {provider}")
@ -44,11 +50,13 @@ def _gemini_contents(prompt: str, history: list[dict] | None) -> list[dict]:
return contents
def gemini_agent_prompt(prompt: str, history: list[dict] | None = None) -> dict:
def gemini_agent_prompt(
prompt: str, history: list[dict] | None = None, model: str | None = None
) -> dict:
api_key = settings.GOOGLE_AI_API_KEY
if not api_key:
raise ValueError("VROBBLER_GOOGLE_AI_API_KEY is not set")
model = settings.LLM_MODEL
model = model or settings.LLM_MODEL
response = httpx.post(
GEMINI_GENERATE_CONTENT_URL.format(model=model),
@ -127,3 +135,61 @@ def _parse_opencode_events(raw: str) -> str:
if part.get("text"):
parts.append(part["text"])
return "\n".join(parts)
def openrouter_agent_prompt(
prompt: str, history: list[dict] | None = None, model: str | None = None
) -> dict:
api_key = settings.OPENROUTER_API_KEY
if not api_key:
raise ValueError("VROBBLER_OPENROUTER_API_KEY is not set")
model = model or settings.LLM_MODEL
messages = []
for turn in history or []:
if not turn.get("response"):
continue
messages.append({"role": "user", "content": turn["prompt"]})
messages.append({"role": "assistant", "content": turn["response"]})
messages.append({"role": "user", "content": prompt})
response = httpx.post(
OPENROUTER_CHAT_URL,
headers={"Authorization": f"Bearer {api_key}"},
json={"model": model, "messages": messages},
timeout=120.0,
)
response.raise_for_status()
data = response.json()
try:
text = data["choices"][0]["message"]["content"]
except (KeyError, IndexError) as e:
raise ValueError(f"Unexpected OpenRouter response: {data}") from e
return {"text": text.strip(), "provider": "openrouter", "model": model}
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.
"""
response = httpx.get(OPENROUTER_MODELS_URL, timeout=30.0)
response.raise_for_status()
data = response.json()
models = []
for item in data.get("data", []):
pricing = item.get("pricing") or {}
if (
str(pricing.get("prompt", "1")) == "0"
and str(pricing.get("completion", "1")) == "0"
):
model_id = item.get("id", "")
if not model_id:
continue
models.append({"id": model_id, "name": item.get("name") or model_id})
models.sort(key=lambda m: (m["name"].lower(), m["id"]))
return models

View File

@ -6,6 +6,16 @@ app_name = "agents"
urlpatterns = [
path("agents/", views.AgentSessionListView.as_view(), name="agent_session_list"),
path(
"agents/select/",
views.AgentModelSelectView.as_view(),
name="agent_model_select",
),
path(
"agents/scrobble-from-model/",
views.AgentScrobbleFromModelView.as_view(),
name="agent_scrobble_from_model",
),
path(
"agents/<slug:slug>/",
views.AgentSessionDetailView.as_view(),

View File

@ -1,6 +1,18 @@
import logging
from agents.models import AgentSession
from agents.providers import list_openrouter_free_models
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views import View
from django.views.generic import TemplateView
from scrobbles.scrobblers import manual_scrobble_agent_session
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
logger = logging.getLogger(__name__)
class AgentSessionListView(ScrobbleableListView):
model = AgentSession
@ -8,3 +20,49 @@ class AgentSessionListView(ScrobbleableListView):
class AgentSessionDetailView(ScrobbleableDetailView):
model = AgentSession
class AgentModelSelectView(LoginRequiredMixin, TemplateView):
template_name = "agents/agent_model_select.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["prompt"] = self.request.session.get("agent_prompt", "").strip()
try:
models = 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:
self.request.session["agent_models"] = models
return context
class AgentScrobbleFromModelView(LoginRequiredMixin, View):
http_method_names = ["post"]
def post(self, request, *args, **kwargs):
model = request.POST.get("model", "").strip()
prompt = (
request.POST.get("prompt", "").strip()
or request.session.get("agent_prompt", "").strip()
)
if not model:
messages.error(request, "No model selected.")
return HttpResponseRedirect(reverse("agents:agent_model_select"))
if not prompt:
messages.error(request, "Your prompt is empty.")
return HttpResponseRedirect(reverse("agents:agent_model_select"))
scrobble = manual_scrobble_agent_session(
prompt, request.user.id, provider="openrouter", model=model
)
if scrobble:
request.session.pop("agent_prompt", None)
request.session.pop("agent_models", None)
return HttpResponseRedirect(scrobble.get_absolute_url())
messages.error(request, "Could not start agent session.")
return HttpResponseRedirect(reverse("agents:agent_model_select"))

View File

@ -22,7 +22,7 @@ class ScrobbleForm(forms.Form):
widget=forms.TextInput(
attrs={
"class": "form-control form-control-dark w-100",
"placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID, -f Food name - calories, or type a question for Gemini)",
"placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID, -f Food name - calories, or type a question for an AI agent)",
"aria-label": "Scrobble something",
}
),
@ -146,6 +146,7 @@ class NotesDictField(forms.Field):
if isinstance(value, str):
if value.strip():
from scrobbles.utils import make_note_timestamp
return {make_note_timestamp(): value.strip()}
return {}

View File

@ -1596,6 +1596,8 @@ def manual_scrobble_agent_session(
prompt: str,
user_id: int,
source: str = "Vrobbler",
provider: str | None = None,
model: str | None = None,
):
"""Start (or append to) an agent session scrobble for a prompt.
@ -1610,8 +1612,10 @@ def manual_scrobble_agent_session(
from agents.models import AgentSession
from django.conf import settings
provider = settings.AGENT_PROVIDER
model = settings.LLM_MODEL if provider == "gemini" else provider
provider = provider or settings.AGENT_PROVIDER
model = model or (
settings.LLM_MODEL if provider in ("gemini", "openrouter") else provider
)
agent_session, _ = AgentSession.find_or_create(provider=provider, model=model)
@ -1683,7 +1687,7 @@ def manual_scrobble_agent_follow_up(
log = scrobble.log if isinstance(scrobble.log, dict) else {}
provider = log.get("provider") or settings.AGENT_PROVIDER
model = log.get("model") or (
settings.LLM_MODEL if provider == "gemini" else provider
settings.LLM_MODEL if provider in ("gemini", "openrouter") else provider
)
prompt_id = _append_agent_turn(

View File

@ -135,6 +135,7 @@ def scrobble_agent_session_prompt(scrobble_id, prompt_id):
prompt = turn.get("prompt", "")
provider = log.get("provider") or settings.AGENT_PROVIDER
model = log.get("model")
history = []
for prior_turn in log.get("turns", []):
@ -146,7 +147,7 @@ def scrobble_agent_session_prompt(scrobble_id, prompt_id):
)
try:
result = agent_prompt(prompt, provider=provider, history=history)
result = agent_prompt(prompt, provider=provider, history=history, model=model)
turn["response"] = result["text"]
log["provider"] = result["provider"]
log["model"] = result["model"]

View File

@ -684,11 +684,8 @@ class ManualScrobbleView(FormView):
return HttpResponseRedirect(reverse("foods:food_search") + f"?q={item_id}")
if key not in MANUAL_SCROBBLE_FNS:
scrobble = manual_scrobble_agent_session(item_str, self.request.user.id)
if scrobble:
return HttpResponseRedirect(scrobble.get_absolute_url())
messages.error(self.request, "Could not start agent session.")
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER", "/"))
self.request.session["agent_prompt"] = item_str
return HttpResponseRedirect(reverse("agents:agent_model_select"))
scrobble_fn = MANUAL_SCROBBLE_FNS[key]
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)

View File

@ -88,9 +88,10 @@ TODOIST_CLIENT_SECRET = os.getenv("VROBBLER_TODOIST_CLIENT_SECRET", "")
GOOGLE_API_KEY = os.getenv("VROBBLER_GOOGLE_API_KEY", "")
GOOGLE_AI_API_KEY = os.getenv("VROBBLER_GOOGLE_AI_API_KEY", "")
LICHESS_API_KEY = os.getenv("VROBBLER_LICHESS_API_KEY", "")
OPENROUTER_API_KEY = os.getenv("VROBBLER_OPENROUTER_API_KEY", "")
AGENT_PROVIDER = os.getenv("VROBBLER_AGENT_PROVIDER", "gemini")
LLM_MODEL = os.getenv("VROBBLER_LLM_MODEL", "gemini-3.6-flash")
AGENT_PROVIDER = os.getenv("VROBBLER_AGENT_PROVIDER", "openrouter")
LLM_MODEL = os.getenv("VROBBLER_LLM_MODEL", "openai/gpt-oss-20b:free")
OPENCODE_CMD = os.getenv("VROBBLER_OPENCODE_CMD", "opencode")
AMAZON_PAAPI_ACCESS_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_ACCESS_KEY", "")

View File

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block content %}
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Choose an agent model</h1>
</div>
<div class="container" style="margin-bottom: 100px;">
{% if prompt %}
<p class="text-muted">Your prompt:</p>
<blockquote class="blockquote">{{ prompt|linebreaksbr }}</blockquote>
{% else %}
<div class="no-results">
<p>No prompt was found. Use the scrobble bar to ask a question first.</p>
</div>
{% endif %}
{% if models_error %}
<div class="alert alert-danger">{{ models_error }}</div>
{% endif %}
{% if models %}
<p class="text-muted">{{ models|length }} free model{{ models|length|pluralize }} available via OpenRouter.</p>
<div class="list-group">
{% for model in models %}
<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>
<form method="post" action="{% url 'agents:agent_scrobble_from_model' %}">
{% csrf_token %}
<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>
</form>
</div>
{% endfor %}
</div>
{% elif prompt and not models_error %}
<div class="no-results">
No free models are available from OpenRouter right now.
</div>
{% endif %}
</div>
</main>
{% endblock %}