[agents] Swap agent flow to OpenRouter with model selection
This commit is contained in:
37
PROJECT.org
37
PROJECT.org
@ -185,7 +185,7 @@ events).
|
|||||||
- Timestamp picker (default to now)
|
- Timestamp picker (default to now)
|
||||||
- Optional: barcode scanning for food/drinks via CameraX
|
- 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] 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:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
: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
|
come from there, with any susequent prompts going to thes ame model (with
|
||||||
context of past prompts and response).
|
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:
|
** TODO [#B] Mood scrobble page with "Check-in complete!" should ask for context :moods:
|
||||||
|
|
||||||
*** Description
|
*** 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
|
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.
|
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]
|
* Version 63.3 [1/1]
|
||||||
** DONE [#A] Fix overuse of OSM API on reverse geocodes :bug:geolocations:
|
** DONE [#A] Fix overuse of OSM API on reverse geocodes :bug:geolocations:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -8,7 +8,9 @@ from agents.providers import (
|
|||||||
_parse_opencode_events,
|
_parse_opencode_events,
|
||||||
agent_prompt,
|
agent_prompt,
|
||||||
gemini_agent_prompt,
|
gemini_agent_prompt,
|
||||||
|
list_openrouter_free_models,
|
||||||
opencode_agent_prompt,
|
opencode_agent_prompt,
|
||||||
|
openrouter_agent_prompt,
|
||||||
)
|
)
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
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"):
|
def _mk_scrobble(user, *, in_progress=True, turns=None, title="First prompt"):
|
||||||
agent_session, _ = AgentSession.find_or_create(
|
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(
|
scrobble = Scrobble.create_or_update(
|
||||||
agent_session,
|
agent_session,
|
||||||
@ -49,7 +51,7 @@ def _mk_scrobble(user, *, in_progress=True, turns=None, title="First prompt"):
|
|||||||
skip_in_progress_check=True,
|
skip_in_progress_check=True,
|
||||||
)
|
)
|
||||||
log = scrobble.log if isinstance(scrobble.log, dict) else {}
|
log = scrobble.log if isinstance(scrobble.log, dict) else {}
|
||||||
log["provider"] = "gemini"
|
log["provider"] = settings.AGENT_PROVIDER
|
||||||
log["model"] = settings.LLM_MODEL
|
log["model"] = settings.LLM_MODEL
|
||||||
log["title"] = title
|
log["title"] = title
|
||||||
log["turns"] = turns or []
|
log["turns"] = turns or []
|
||||||
@ -214,7 +216,120 @@ def test_agent_prompt_defaults_to_configured_provider(settings):
|
|||||||
settings.GOOGLE_AI_API_KEY = "test-key"
|
settings.GOOGLE_AI_API_KEY = "test-key"
|
||||||
with patch("agents.providers.gemini_agent_prompt", return_value={"text": "x"}) as m:
|
with patch("agents.providers.gemini_agent_prompt", return_value={"text": "x"}) as m:
|
||||||
agent_prompt("hi")
|
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 ---
|
# --- 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.objects.filter(id=scrobble.id).exists()
|
||||||
assert scrobble.media_type == Scrobble.MediaType.AGENT_SESSION
|
assert scrobble.media_type == Scrobble.MediaType.AGENT_SESSION
|
||||||
assert scrobble.in_progress is True
|
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.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?"
|
assert scrobble.log["title"] == "What is the capital of France?"
|
||||||
turns = scrobble.log["turns"]
|
turns = scrobble.log["turns"]
|
||||||
assert len(turns) == 1
|
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")
|
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||||
def test_manual_scrobble_agent_session_reuses_agent_session(mock_delay, user):
|
def test_manual_scrobble_agent_session_reuses_agent_session(mock_delay, user):
|
||||||
agent_session, created = AgentSession.find_or_create(
|
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
|
assert created is True
|
||||||
|
|
||||||
manual_scrobble_agent_session("First question?", user.id)
|
manual_scrobble_agent_session("First question?", user.id)
|
||||||
|
|
||||||
agent_session_again, created = AgentSession.find_or_create(
|
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 created is False
|
||||||
assert agent_session_again.id == agent_session.id
|
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 = {
|
mock_agent_prompt.return_value = {
|
||||||
"text": "hi back",
|
"text": "hi back",
|
||||||
"provider": "gemini",
|
"provider": settings.AGENT_PROVIDER,
|
||||||
"model": "gemini-2.5-flash",
|
"model": settings.LLM_MODEL,
|
||||||
}
|
}
|
||||||
|
|
||||||
scrobble_agent_session_prompt(scrobble.id, "abc-123")
|
scrobble_agent_session_prompt(scrobble.id, "abc-123")
|
||||||
|
|
||||||
scrobble.refresh_from_db()
|
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["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.in_progress is True
|
||||||
assert scrobble.played_to_completion is False
|
assert scrobble.played_to_completion is False
|
||||||
mock_complete.assert_called_once_with(
|
mock_complete.assert_called_once_with(
|
||||||
@ -363,15 +483,16 @@ def test_scrobble_agent_session_prompt_passes_history(
|
|||||||
|
|
||||||
mock_agent_prompt.return_value = {
|
mock_agent_prompt.return_value = {
|
||||||
"text": "12",
|
"text": "12",
|
||||||
"provider": "gemini",
|
"provider": settings.AGENT_PROVIDER,
|
||||||
"model": settings.LLM_MODEL,
|
"model": settings.LLM_MODEL,
|
||||||
}
|
}
|
||||||
scrobble_agent_session_prompt(scrobble.id, "cur-1")
|
scrobble_agent_session_prompt(scrobble.id, "cur-1")
|
||||||
|
|
||||||
mock_agent_prompt.assert_called_once_with(
|
mock_agent_prompt.assert_called_once_with(
|
||||||
"and times 3?",
|
"and times 3?",
|
||||||
provider="gemini",
|
provider=settings.AGENT_PROVIDER,
|
||||||
history=[{"prompt": "what is 2+2?", "response": "4"}],
|
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?"},
|
{"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
|
assert response.status_code == 302
|
||||||
scrobble = Scrobble.objects.filter(user_id=user.id).first()
|
scrobble = Scrobble.objects.filter(user_id=user.id).first()
|
||||||
assert scrobble is not None
|
assert scrobble is not None
|
||||||
assert scrobble.media_type == Scrobble.MediaType.AGENT_SESSION
|
assert scrobble.agent_session.provider == "openrouter"
|
||||||
assert response.url.startswith(reverse("scrobbles:detail", args=[scrobble.id]))
|
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")
|
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
|
||||||
|
|||||||
@ -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_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>"
|
||||||
|
|
||||||
|
|||||||
@ -12,11 +12,15 @@ GEMINI_GENERATE_CONTENT_URL = (
|
|||||||
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
"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(
|
def agent_prompt(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
history: list[dict] | None = None,
|
history: list[dict] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Send a prompt to the configured agent provider and return the response.
|
"""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
|
provider = provider or settings.AGENT_PROVIDER
|
||||||
if provider == "gemini":
|
if provider == "gemini":
|
||||||
return gemini_agent_prompt(prompt, history=history)
|
return gemini_agent_prompt(prompt, history=history, model=model)
|
||||||
if provider == "opencode":
|
if provider == "opencode":
|
||||||
return opencode_agent_prompt(prompt, history=history)
|
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}")
|
raise ValueError(f"Unknown agent provider: {provider}")
|
||||||
|
|
||||||
|
|
||||||
@ -44,11 +50,13 @@ def _gemini_contents(prompt: str, history: list[dict] | None) -> list[dict]:
|
|||||||
return contents
|
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
|
api_key = settings.GOOGLE_AI_API_KEY
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise ValueError("VROBBLER_GOOGLE_AI_API_KEY is not set")
|
raise ValueError("VROBBLER_GOOGLE_AI_API_KEY is not set")
|
||||||
model = settings.LLM_MODEL
|
model = model or settings.LLM_MODEL
|
||||||
|
|
||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
GEMINI_GENERATE_CONTENT_URL.format(model=model),
|
GEMINI_GENERATE_CONTENT_URL.format(model=model),
|
||||||
@ -127,3 +135,61 @@ def _parse_opencode_events(raw: str) -> str:
|
|||||||
if part.get("text"):
|
if part.get("text"):
|
||||||
parts.append(part["text"])
|
parts.append(part["text"])
|
||||||
return "\n".join(parts)
|
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
|
||||||
|
|||||||
@ -6,6 +6,16 @@ app_name = "agents"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("agents/", views.AgentSessionListView.as_view(), name="agent_session_list"),
|
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(
|
path(
|
||||||
"agents/<slug:slug>/",
|
"agents/<slug:slug>/",
|
||||||
views.AgentSessionDetailView.as_view(),
|
views.AgentSessionDetailView.as_view(),
|
||||||
|
|||||||
@ -1,6 +1,18 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from agents.models import AgentSession
|
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
|
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AgentSessionListView(ScrobbleableListView):
|
class AgentSessionListView(ScrobbleableListView):
|
||||||
model = AgentSession
|
model = AgentSession
|
||||||
@ -8,3 +20,49 @@ class AgentSessionListView(ScrobbleableListView):
|
|||||||
|
|
||||||
class AgentSessionDetailView(ScrobbleableDetailView):
|
class AgentSessionDetailView(ScrobbleableDetailView):
|
||||||
model = AgentSession
|
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"))
|
||||||
|
|||||||
@ -22,7 +22,7 @@ class ScrobbleForm(forms.Form):
|
|||||||
widget=forms.TextInput(
|
widget=forms.TextInput(
|
||||||
attrs={
|
attrs={
|
||||||
"class": "form-control form-control-dark w-100",
|
"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",
|
"aria-label": "Scrobble something",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@ -146,6 +146,7 @@ class NotesDictField(forms.Field):
|
|||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
if value.strip():
|
if value.strip():
|
||||||
from scrobbles.utils import make_note_timestamp
|
from scrobbles.utils import make_note_timestamp
|
||||||
|
|
||||||
return {make_note_timestamp(): value.strip()}
|
return {make_note_timestamp(): value.strip()}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
@ -1596,6 +1596,8 @@ def manual_scrobble_agent_session(
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
source: str = "Vrobbler",
|
source: str = "Vrobbler",
|
||||||
|
provider: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
):
|
):
|
||||||
"""Start (or append to) an agent session scrobble for a prompt.
|
"""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 agents.models import AgentSession
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
provider = settings.AGENT_PROVIDER
|
provider = provider or settings.AGENT_PROVIDER
|
||||||
model = settings.LLM_MODEL if provider == "gemini" else provider
|
model = model or (
|
||||||
|
settings.LLM_MODEL if provider in ("gemini", "openrouter") else provider
|
||||||
|
)
|
||||||
|
|
||||||
agent_session, _ = AgentSession.find_or_create(provider=provider, model=model)
|
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 {}
|
log = scrobble.log if isinstance(scrobble.log, dict) else {}
|
||||||
provider = log.get("provider") or settings.AGENT_PROVIDER
|
provider = log.get("provider") or settings.AGENT_PROVIDER
|
||||||
model = log.get("model") or (
|
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(
|
prompt_id = _append_agent_turn(
|
||||||
|
|||||||
@ -135,6 +135,7 @@ def scrobble_agent_session_prompt(scrobble_id, prompt_id):
|
|||||||
|
|
||||||
prompt = turn.get("prompt", "")
|
prompt = turn.get("prompt", "")
|
||||||
provider = log.get("provider") or settings.AGENT_PROVIDER
|
provider = log.get("provider") or settings.AGENT_PROVIDER
|
||||||
|
model = log.get("model")
|
||||||
|
|
||||||
history = []
|
history = []
|
||||||
for prior_turn in log.get("turns", []):
|
for prior_turn in log.get("turns", []):
|
||||||
@ -146,7 +147,7 @@ def scrobble_agent_session_prompt(scrobble_id, prompt_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = agent_prompt(prompt, provider=provider, history=history)
|
result = agent_prompt(prompt, provider=provider, history=history, model=model)
|
||||||
turn["response"] = result["text"]
|
turn["response"] = result["text"]
|
||||||
log["provider"] = result["provider"]
|
log["provider"] = result["provider"]
|
||||||
log["model"] = result["model"]
|
log["model"] = result["model"]
|
||||||
|
|||||||
@ -684,11 +684,8 @@ class ManualScrobbleView(FormView):
|
|||||||
return HttpResponseRedirect(reverse("foods:food_search") + f"?q={item_id}")
|
return HttpResponseRedirect(reverse("foods:food_search") + f"?q={item_id}")
|
||||||
|
|
||||||
if key not in MANUAL_SCROBBLE_FNS:
|
if key not in MANUAL_SCROBBLE_FNS:
|
||||||
scrobble = manual_scrobble_agent_session(item_str, self.request.user.id)
|
self.request.session["agent_prompt"] = item_str
|
||||||
if scrobble:
|
return HttpResponseRedirect(reverse("agents:agent_model_select"))
|
||||||
return HttpResponseRedirect(scrobble.get_absolute_url())
|
|
||||||
messages.error(self.request, "Could not start agent session.")
|
|
||||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER", "/"))
|
|
||||||
|
|
||||||
scrobble_fn = MANUAL_SCROBBLE_FNS[key]
|
scrobble_fn = MANUAL_SCROBBLE_FNS[key]
|
||||||
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)
|
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)
|
||||||
|
|||||||
@ -88,9 +88,10 @@ TODOIST_CLIENT_SECRET = os.getenv("VROBBLER_TODOIST_CLIENT_SECRET", "")
|
|||||||
GOOGLE_API_KEY = os.getenv("VROBBLER_GOOGLE_API_KEY", "")
|
GOOGLE_API_KEY = os.getenv("VROBBLER_GOOGLE_API_KEY", "")
|
||||||
GOOGLE_AI_API_KEY = os.getenv("VROBBLER_GOOGLE_AI_API_KEY", "")
|
GOOGLE_AI_API_KEY = os.getenv("VROBBLER_GOOGLE_AI_API_KEY", "")
|
||||||
LICHESS_API_KEY = os.getenv("VROBBLER_LICHESS_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")
|
AGENT_PROVIDER = os.getenv("VROBBLER_AGENT_PROVIDER", "openrouter")
|
||||||
LLM_MODEL = os.getenv("VROBBLER_LLM_MODEL", "gemini-3.6-flash")
|
LLM_MODEL = os.getenv("VROBBLER_LLM_MODEL", "openai/gpt-oss-20b:free")
|
||||||
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", "")
|
||||||
|
|||||||
49
vrobbler/templates/agents/agent_model_select.html
Normal file
49
vrobbler/templates/agents/agent_model_select.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user