diff --git a/PROJECT.org b/PROJECT.org index 9e9a1b6..7ae5f91 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -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: diff --git a/tests/agents_tests/test_agent_sessions.py b/tests/agents_tests/test_agent_sessions.py index 2127019..05f6844 100644 --- a/tests/agents_tests/test_agent_sessions.py +++ b/tests/agents_tests/test_agent_sessions.py @@ -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") diff --git a/vrobbler.conf.example b/vrobbler.conf.example index b96300f..97537f8 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_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 e4599b4..b04b602 100644 --- a/vrobbler/apps/agents/providers.py +++ b/vrobbler/apps/agents/providers.py @@ -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 diff --git a/vrobbler/apps/agents/urls.py b/vrobbler/apps/agents/urls.py index 7715e01..81bf866 100644 --- a/vrobbler/apps/agents/urls.py +++ b/vrobbler/apps/agents/urls.py @@ -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//", views.AgentSessionDetailView.as_view(), diff --git a/vrobbler/apps/agents/views.py b/vrobbler/apps/agents/views.py index 893d202..5980e94 100644 --- a/vrobbler/apps/agents/views.py +++ b/vrobbler/apps/agents/views.py @@ -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")) diff --git a/vrobbler/apps/scrobbles/forms.py b/vrobbler/apps/scrobbles/forms.py index 0ba6218..847859d 100644 --- a/vrobbler/apps/scrobbles/forms.py +++ b/vrobbler/apps/scrobbles/forms.py @@ -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 {} diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index 1b60886..e870122 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -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( diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.py index f0ffc81..1f349af 100644 --- a/vrobbler/apps/scrobbles/tasks.py +++ b/vrobbler/apps/scrobbles/tasks.py @@ -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"] diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 570caba..a18bf69 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -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) diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 50942dc..58de572 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -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", "") diff --git a/vrobbler/templates/agents/agent_model_select.html b/vrobbler/templates/agents/agent_model_select.html new file mode 100644 index 0000000..c2e59b1 --- /dev/null +++ b/vrobbler/templates/agents/agent_model_select.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Choose an agent model

+
+ +
+ {% if prompt %} +

Your prompt:

+
{{ prompt|linebreaksbr }}
+ {% else %} +
+

No prompt was found. Use the scrobble bar to ask a question first.

+
+ {% endif %} + + {% if models_error %} +
{{ models_error }}
+ {% endif %} + + {% if models %} +

{{ models|length }} free model{{ models|length|pluralize }} available via OpenRouter.

+
+ {% for model in models %} +
+
+
{{ model.name }}
+
{{ model.id }}
+
+
+ {% csrf_token %} + + + +
+
+ {% endfor %} +
+ {% elif prompt and not models_error %} +
+ No free models are available from OpenRouter right now. +
+ {% endif %} +
+
+{% endblock %}