fixed api calls with seerr, added full context for models, beginning to standardizing single id as source of truths for future tools
Build and Push Agent API / build (push) Successful in 14s

This commit is contained in:
2026-05-14 14:25:48 +02:00
parent d943d4bd31
commit 2adf17493a
5 changed files with 692 additions and 161 deletions
+1
View File
@@ -174,3 +174,4 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
.docs/
+221
View File
@@ -0,0 +1,221 @@
# API Architecture — Agent + Skill + Tool Pipeline
This document explains how the API routes user messages through the agent/skill/tool pipeline to produce responses.
---
## Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ OpenWebUI / Client │
│ POST /v1/chat/completions { model, messages, stream } │
└──────────────────────────────┬──────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ api/v1/chat.py — chat_completions() │
│ │
│ 1. _resolve_agent(req.model) → Agent │
│ 2. agent.build_system_prompt() → system prompt │
│ 3. Build full_messages = [system] + req.messages │
│ 4. run_agent_with_tools(client, messages, agent_id) │
└──────────────────────────────┬───────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Tool-Calling Loop (run_agent_with_tools / run_agent_stream) │
│ │
│ while turns < max_turns: │
│ response = LLM.chat(messages, tools=agent_tools) │
│ if response has tool_calls: │
│ for each tool_call: │
│ result = execute_tool(skills, name, args) │
│ append result to messages │
│ else: │
│ return response.text (stream tokens if streaming) │
└──────────────────────────────────────────────────────────────────┘
```
---
## Key Concepts
### 1. Agent
An **Agent** is a persona + skill bundle. Defined in `agents/`.
```python
# agents/media_agent.py
Agent(
agent_id="media-agent",
description="Media assistant with Seerr integration",
skills=["media_info", "seerr", "triage"],
base_prompt="You are a media assistant...",
)
```
- `agent_id` — unique name, exposed as a model in OpenWebUI
- `skills` — list of skill names to load
- `base_prompt` — starting system prompt, combined with skill fragments
- `build_system_prompt()` — merges base_prompt + all skill prompt fragments
Agents self-register at import time via `agents/__init__.py`'s `register()`.
`main.py` calls `load_all_agents()` at startup to import all agent/skill modules.
### 2. Skill
A **Skill** is a capability bundle. Defined in `skills/`.
```python
# skills/seerr.py
Skill(
name="seerr",
description="Seerr integration — trending, discover, request media, submit issues",
prompt_fragment="## Seerr Media Tools\n...",
tools=[...], # OpenAI function-calling schema
execute=_execute, # async handler: tool_name + args → ToolResult
)
```
- `prompt_fragment` — injected into the agent's system prompt. Teaches the LLM what tools are available and when to use them.
- `tools` — list of OpenAI function definitions (name, description, parameters).
- `execute` — async callable that routes tool calls to API handlers.
### 3. Tool
A **Tool** is a single function the LLM can call. Defined as part of a skill's `tools` list.
```python
{
"type": "function",
"function": {
"name": "seerr_trending",
"description": "Get trending movies and TV shows from Seerr...",
"parameters": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["movie", "tv", "all"]},
"language": {"type": "string"},
},
"required": ["kind"],
},
},
}
```
When the LLM responds with a tool call, the loop:
1. Extracts `function.name` (e.g. `"seerr_trending"`) and `function.arguments` (e.g. `{"kind": "movie"}`)
2. Calls `execute_tool(agent.skills, name, args)` which finds the owning skill and runs it
3. Appends the result text to the message history
4. Sends back to the LLM for a follow-up response
---
## Full Request Flow
### Step-by-step: "What are trending movies?"
```
1. OpenWebUI sends:
POST /v1/chat/completions
{
"model": "media-agent",
"messages": [
{"role": "user", "content": "What are trending movies?"}
],
"stream": false
}
2. chat_completions():
→ _resolve_agent(model="media-agent")
→ get_agent("media-agent") → Agent(skills=["media_info", "seerr", "triage"])
→ tools = get_all_tools(["media_info", "seerr", "triage"])
→ Returns 7 tool definitions from seerr.py
→ system_prompt = agent.build_system_prompt()
→ base_prompt + media_info fragment + seerr fragment + triage fragment
3. run_agent_with_tools() — Turn 1:
→ LLM receives: [system prompt with tools] + [user: "What are trending movies?"]
→ LLM responds: tool_calls = [{"function": {"name": "seerr_trending", "arguments": {"kind": "movie"}}}]
4. Execute tool:
→ execute_tool(["media_info", "seerr", "triage"], "seerr_trending", {"kind": "movie"})
→ Finds seerr skill → calls _execute("seerr_trending", ...) → _trending(args)
→ GET /api/v1/discover/trending?mediaType=movie
→ Returns formatted list with [tmdb:IDs]
5. run_agent_with_tools() — Turn 2:
→ LLM receives: previous messages + [tool: "Found 20 trending movies..."]
→ LLM responds: text = "Here are the top trending movies! 🎬 ..."
→ finish_reason="stop" → return the text
6. chat_completions() returns:
{ "choices": [{"message": {"content": "Here are the top trending movies!..."}}] }
```
### Step-by-step: "Request the 2026 one" (multi-turn context)
```
1. OpenWebUI sends the FULL history:
{
"model": "media-agent",
"messages": [
{"role": "user", "content": "What are trending movies?"},
{"role": "assistant", "content": "Here are the top 10 trending movies!
1. **Mortal Kombat II** (2026) [tmdb:931285] — ..."},
{"role": "user", "content": "could request the mortal kombat one?"},
{"role": "assistant", "content": "There are several Mortal Kombat entries! ..."},
{"role": "user", "content": "the 2026 one"}
]
}
2. chat_completions():
→ req.messages contains the ENTIRE conversation history
→ System prompt prepended → full_messages = [system] + 5 history messages
→ LLM sees everything: the trending list with [tmdb:931285], the disambiguation, "the 2026 one"
3. LLM reasons:
- I previously listed Mortal Kombat II (2026) with [tmdb:931285]
- The user said "request the mortal kombat one" → I searched and showed 4 options
- Now they say "the 2026 one" → that matches Mortal Kombat II (2026) [tmdb:931285]
- I should call seerr_request_media(kind="movie", title="Mortal Kombat II", tmdb_id=931285)
4. Tool executes the request → ✅ Success
```
---
## File Map
```
main.py # FastAPI app entry point, creates singletons
├── core/
│ ├── config.py # .env loader, config constants
│ └── llm.py # create_client() factory for OpenAI client
├── api/
│ ├── dependencies.py # FastAPI Depends: get_llm_client()
│ └── v1/
│ └── chat.py # APIRouter, endpoints, tool-calling loop
├── agents/
│ ├── __init__.py # Agent dataclass, registry, load_all_agents()
│ ├── naked.py # Agent: barebone LLM, no skills
│ └── media_agent.py # Agent: media assistant with Seerr skills
└── skills/
├── __init__.py # Skill dataclass, ToolResult, registry, execution
├── media_info.py # Skill: base media assistant persona (prompt-only)
├── seerr.py # Skill: Seerr API tools (7 tools, real API calls)
└── triage.py # Skill: fallback for unsupported actions (prompt-only)
```
## Key Design Decisions
1. **Full multi-turn history**: `req.messages` passes through unchanged. The LLM has access to its own previous responses (including `[tmdb:IDs]`). No external state management needed.
2. **No deterministic pre-processing**: No affirmation detectors, reference resolvers, or hardcoded rules. The LLM interprets user intent naturally from full conversation context.
3. **Agent selection via `model` field**: OpenWebUI sends `model` in the request. `_resolve_agent()` maps it to a registered agent. The `/v1/models` endpoint lists all agents as selectable models.
4. **Skills = prompts + tools**: Skills inject prompt fragments AND optionally expose OpenAI function-calling tools. Prompt-only skills (like `triage`) just shape behavior. Tool-enabled skills (like `seerr`) let the LLM take real actions.
5. **Singleton LLM client**: Created once in `main.py`, stored on `app.state.llm_client`, accessed via FastAPI `Depends(get_llm_client)`.
+1 -1
View File
@@ -3,5 +3,5 @@ from openai import OpenAI
def get_llm_client(request: Request) -> OpenAI: def get_llm_client(request: Request) -> OpenAI:
"""FastAPI dependency - returns the singleton OpenAI client from app.state.""" """FastAPI dependency returns the singleton OpenAI client from app.state."""
return request.app.state.llm_client return request.app.state.llm_client
+72 -111
View File
@@ -7,7 +7,7 @@ import asyncio
from api.dependencies import get_llm_client from api.dependencies import get_llm_client
from agents import get as get_agent, list_all as list_all_agents from agents import get as get_agent, list_all as list_all_agents
from skills import get_all_tools, execute_tool, ToolResult from skills import get_all_tools, execute_tool
router = APIRouter() router = APIRouter()
@@ -15,7 +15,7 @@ router = APIRouter()
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
message: str message: str
session_id: str | None = None session_id: str | None = None
agent_id: str | None = None # which agent to use ("naked", "media-agent", …) agent_id: str | None = None
class ChatCompletionRequest(BaseModel): class ChatCompletionRequest(BaseModel):
@@ -30,7 +30,6 @@ class ChatCompletionRequest(BaseModel):
def _resolve_agent(agent_id: str | None = None, model: str | None = None): def _resolve_agent(agent_id: str | None = None, model: str | None = None):
""" """
Resolution order:
1. explicit agent_id 1. explicit agent_id
2. model field (OpenWebUI sends this — maps to agent_id if registered) 2. model field (OpenWebUI sends this — maps to agent_id if registered)
3. fallback to "naked" 3. fallback to "naked"
@@ -48,23 +47,18 @@ def _resolve_agent(agent_id: str | None = None, model: str | None = None):
async def run_agent_with_tools( async def run_agent_with_tools(
client: OpenAI, client: OpenAI,
message: str, messages: list[dict],
agent_id: str | None = None, agent_id: str | None = None,
model: str | None = None, model: str | None = None,
max_turns: int = 5, max_turns: int = 5,
) -> str: ) -> str:
"""Send the user message to the LLM with tool definitions. """Send messages to the LLM with tool definitions. Tool-calling loop."""
Loop: if the LLM responds with tool_calls, execute them and feed
results back until the LLM produces a final text answer.
"""
agent = _resolve_agent(agent_id, model) agent = _resolve_agent(agent_id, model)
tools = get_all_tools(agent.skills) tools = get_all_tools(agent.skills)
system_prompt = agent.build_system_prompt() system_prompt = agent.build_system_prompt()
messages: list[dict] = [ full_messages: list[dict] = [{"role": "system", "content": system_prompt}]
{"role": "system", "content": system_prompt}, full_messages.extend(messages)
{"role": "user", "content": message},
]
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -73,129 +67,89 @@ async def run_agent_with_tools(
None, None,
lambda: client.chat.completions.create( lambda: client.chat.completions.create(
model="deepseek-chat", model="deepseek-chat",
messages=messages, messages=full_messages,
tools=tools if tools else None, tools=tools if tools else None,
tool_choice="auto" if tools else None, tool_choice="auto" if tools else None,
), ),
) )
choice = resp.choices[0] choice = resp.choices[0]
# If the model sends a final text answer, return it
if choice.finish_reason == "stop" and choice.message.content: if choice.finish_reason == "stop" and choice.message.content:
return choice.message.content return choice.message.content
# If the model wants to call tools
if choice.message.tool_calls: if choice.message.tool_calls:
# Append the assistant message with tool_calls full_messages.append(choice.message.model_dump(exclude_none=True))
messages.append(choice.message.model_dump(exclude_none=True))
for tc in choice.message.tool_calls: for tc in choice.message.tool_calls:
fn_name = tc.function.name fn_name = tc.function.name
fn_args = json.loads(tc.function.arguments) fn_args = json.loads(tc.function.arguments)
tr = await execute_tool(agent.skills, fn_name, fn_args) tr = await execute_tool(agent.skills, fn_name, fn_args)
result = tr.content if tr else f"Tool '{fn_name}' is not available right now." result = tr.content if tr else f"Tool '{fn_name}' is not available."
messages.append({ full_messages.append({
"role": "tool", "role": "tool", "tool_call_id": tc.id, "content": result,
"tool_call_id": tc.id,
"content": result,
}) })
continue continue
# Fallback — should not normally happen
return choice.message.content or "I'm not sure how to help with that." return choice.message.content or "I'm not sure how to help with that."
return "I've taken several actions but still need more information. Could you clarify?" return "I've taken several actions but still need more information. Could you clarify?"
# ---------------------------------------------------------------------------
# Non-streaming helper (no tools — used by sync endpoint if tools are absent)
# ---------------------------------------------------------------------------
def run_agent_simple(
client: OpenAI,
message: str,
agent_id: str | None = None,
model: str | None = None,
) -> str:
"""Plain LLM call — no tools. Used when the agent has no tool-enabled skills."""
agent = _resolve_agent(agent_id, model)
response = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": agent.build_system_prompt()},
{"role": "user", "content": message},
],
)
return response.choices[0].message.content
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Streaming generators # Streaming generators
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _stream_with_tools( async def _stream_with_tools(
client: OpenAI, client: OpenAI,
message: str, messages: list[dict],
agent_id: str | None = None, agent_id: str | None = None,
model: str | None = None, model: str | None = None,
max_turns: int = 5, max_turns: int = 5,
): ):
"""Streaming version with tool-calling loop. """Streaming tool-calling loop. Tools run silently, final text is streamed."""
Yields tokens from the final text response (tools run silently in the background).
"""
agent = _resolve_agent(agent_id, model) agent = _resolve_agent(agent_id, model)
tools = get_all_tools(agent.skills) tools = get_all_tools(agent.skills)
system_prompt = agent.build_system_prompt() system_prompt = agent.build_system_prompt()
messages: list[dict] = [ full_messages: list[dict] = [{"role": "system", "content": system_prompt}]
{"role": "system", "content": system_prompt}, full_messages.extend(messages)
{"role": "user", "content": message},
]
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
for turn in range(max_turns): for turn in range(max_turns):
# Non-streaming call to check for tool_calls
resp = await loop.run_in_executor( resp = await loop.run_in_executor(
None, None,
lambda: client.chat.completions.create( lambda: client.chat.completions.create(
model="deepseek-chat", model="deepseek-chat",
messages=messages, messages=full_messages,
tools=tools if tools else None, tools=tools if tools else None,
tool_choice="auto" if tools else None, tool_choice="auto" if tools else None,
), ),
) )
choice = resp.choices[0] choice = resp.choices[0]
# Tool calls? Execute them and loop
if choice.message.tool_calls: if choice.message.tool_calls:
messages.append(choice.message.model_dump(exclude_none=True)) full_messages.append(choice.message.model_dump(exclude_none=True))
for tc in choice.message.tool_calls: for tc in choice.message.tool_calls:
fn_name = tc.function.name fn_name = tc.function.name
fn_args = json.loads(tc.function.arguments) fn_args = json.loads(tc.function.arguments)
tr = await execute_tool(agent.skills, fn_name, fn_args) tr = await execute_tool(agent.skills, fn_name, fn_args)
result = tr.content if tr else f"Tool '{fn_name}' is not available right now." result = tr.content if tr else f"Tool '{fn_name}' is not available."
messages.append({ full_messages.append({
"role": "tool", "role": "tool",
"tool_call_id": tc.id, "tool_call_id": tc.id,
"content": result, "content": result,
}) })
continue continue
# Final text answer — stream it
if choice.finish_reason == "stop" and choice.message.content: if choice.finish_reason == "stop" and choice.message.content:
# Already have a non-streaming answer — yield it token-by-token
for token in choice.message.content: for token in choice.message.content:
yield token yield token
await asyncio.sleep(0) await asyncio.sleep(0)
return return
# Last resort: stream the final response
def _sync_stream(): def _sync_stream():
stream = client.chat.completions.create( stream = client.chat.completions.create(
model="deepseek-chat", model="deepseek-chat", messages=full_messages, stream=True,
messages=messages,
stream=True,
) )
for chunk in stream: for chunk in stream:
delta = chunk.choices[0].delta delta = chunk.choices[0].delta
@@ -209,12 +163,12 @@ async def _stream_with_tools(
return return
yield token yield token
yield "" yield "\u2026"
async def run_agent_stream( async def run_agent_stream(
client: OpenAI, client: OpenAI,
message: str, messages: list[dict],
agent_id: str | None = None, agent_id: str | None = None,
model: str | None = None, model: str | None = None,
): ):
@@ -223,22 +177,20 @@ async def run_agent_stream(
tools = get_all_tools(agent.skills) tools = get_all_tools(agent.skills)
if tools: if tools:
async for token in _stream_with_tools(client, message, agent_id, model): async for token in _stream_with_tools(client, messages, agent_id, model):
yield token yield token
return return
# No tools — simple streaming # No tools — simple streaming
system_prompt = agent.build_system_prompt() system_prompt = agent.build_system_prompt()
full_messages: list[dict] = [{"role": "system", "content": system_prompt}]
full_messages.extend(messages)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
def _sync_stream(): def _sync_stream():
stream = client.chat.completions.create( stream = client.chat.completions.create(
model="deepseek-chat", model="deepseek-chat", messages=full_messages, stream=True,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": message},
],
stream=True,
) )
for chunk in stream: for chunk in stream:
delta = chunk.choices[0].delta delta = chunk.choices[0].delta
@@ -263,15 +215,17 @@ def root():
@router.post("/chat") @router.post("/chat")
async def chat(req: ChatRequest, client: OpenAI = Depends(get_llm_client)): async def chat(
"""Streaming chat endpoint — returns Server-Sent Events.""" req: ChatRequest,
client: OpenAI = Depends(get_llm_client),
):
"""Streaming chat — single message, no history."""
messages = [{"role": "user", "content": req.message}]
async def event_stream(): async def event_stream():
async for token in run_agent_stream( async for token in run_agent_stream(client, messages, req.agent_id):
client, req.message, req.agent_id,
):
payload = json.dumps({"token": token, "session_id": req.session_id}) payload = json.dumps({"token": token, "session_id": req.session_id})
yield f"data: {payload}\n\n" yield f"data: {payload}\n\n"
yield f"data: {json.dumps({'done': True, 'session_id': req.session_id})}\n\n" yield f"data: {json.dumps({'done': True, 'session_id': req.session_id})}\n\n"
return StreamingResponse( return StreamingResponse(
@@ -286,24 +240,34 @@ async def chat(req: ChatRequest, client: OpenAI = Depends(get_llm_client)):
@router.post("/chat/sync") @router.post("/chat/sync")
async def chat_sync(req: ChatRequest, client: OpenAI = Depends(get_llm_client)): async def chat_sync(
"""Non-streaming endpoint — uses tool-calling when the agent has tools.""" req: ChatRequest,
client: OpenAI = Depends(get_llm_client),
):
"""Non-streaming chat — single message."""
agent = _resolve_agent(req.agent_id) agent = _resolve_agent(req.agent_id)
tools = get_all_tools(agent.skills) tools = get_all_tools(agent.skills)
messages = [{"role": "user", "content": req.message}]
if tools: if tools:
response = await run_agent_with_tools( response = await run_agent_with_tools(client, messages, req.agent_id)
client, req.message, req.agent_id,
)
else: else:
response = run_agent_simple(client, req.message, req.agent_id) agent_obj = _resolve_agent(req.agent_id)
resp = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": agent_obj.build_system_prompt()},
{"role": "user", "content": req.message},
],
)
response = resp.choices[0].message.content
return {"response": response, "session_id": req.session_id} return {"response": response, "session_id": req.session_id}
@router.get("/agents") @router.get("/agents")
def list_agents(): def list_agents():
"""Return all registered agents with their ids, descriptions, and skills.""" """Return all registered agents."""
return { return {
"agents": [ "agents": [
{ {
@@ -318,7 +282,7 @@ def list_agents():
@router.get("/models") @router.get("/models")
def list_models(): def list_models():
"""Return all registered agents as selectable models for OpenWebUI.""" """Return agents as selectable models for OpenWebUI."""
return { return {
"object": "list", "object": "list",
"data": [ "data": [
@@ -339,36 +303,28 @@ async def chat_completions(
client: OpenAI = Depends(get_llm_client), client: OpenAI = Depends(get_llm_client),
): ):
"""OpenAI-compatible /chat/completions — supports stream=True. """OpenAI-compatible /chat/completions — supports stream=True.
Resolves the agent from the model field (OpenWebUI sends this). Multi-turn: req.messages contains the FULL conversation history.
Agent resolved from the model field (OpenWebUI sends this).
""" """
user_message = req.messages[-1]["content"]
agent = _resolve_agent(model=req.model) agent = _resolve_agent(model=req.model)
if req.stream: if req.stream:
async def sse_stream(): async def sse_stream():
async for token in run_agent_stream(client, user_message, agent_id=agent.agent_id): async for token in run_agent_stream(
client, req.messages, agent_id=agent.agent_id,
):
chunk = { chunk = {
"id": "chatcmpl-local", "id": "chatcmpl-local",
"object": "chat.completion.chunk", "object": "chat.completion.chunk",
"choices": [ "choices": [
{ {"index": 0, "delta": {"content": token}, "finish_reason": None}
"index": 0,
"delta": {"content": token},
"finish_reason": None,
}
], ],
} }
yield f"data: {json.dumps(chunk)}\n\n" yield f"data: {json.dumps(chunk)}\n\n"
final_chunk = { final_chunk = {
"id": "chatcmpl-local", "id": "chatcmpl-local",
"object": "chat.completion.chunk", "object": "chat.completion.chunk",
"choices": [ "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
{
"index": 0,
"delta": {},
"finish_reason": "stop",
}
],
} }
yield f"data: {json.dumps(final_chunk)}\n\n" yield f"data: {json.dumps(final_chunk)}\n\n"
yield "data: [DONE]\n\n" yield "data: [DONE]\n\n"
@@ -376,18 +332,23 @@ async def chat_completions(
return StreamingResponse( return StreamingResponse(
sse_stream(), sse_stream(),
media_type="text/event-stream", media_type="text/event-stream",
headers={ headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
) )
# Non-streaming path # Non-streaming — full history, tool-calling
tools = get_all_tools(agent.skills) tools = get_all_tools(agent.skills)
if tools: if tools:
response = await run_agent_with_tools(client, user_message, agent_id=agent.agent_id) response = await run_agent_with_tools(
client, req.messages, agent_id=agent.agent_id,
)
else: else:
response = run_agent_simple(client, user_message, agent_id=agent.agent_id) system_prompt = agent.build_system_prompt()
full_msgs: list[dict] = [{"role": "system", "content": system_prompt}]
full_msgs.extend(req.messages)
resp = client.chat.completions.create(
model="deepseek-chat", messages=full_msgs,
)
response = resp.choices[0].message.content
return { return {
"id": "chatcmpl-local", "id": "chatcmpl-local",
+396 -48
View File
@@ -37,7 +37,8 @@ def _client() -> httpx.AsyncClient:
def _fmt_items(items: list[dict], kind: str) -> str: def _fmt_items(items: list[dict], kind: str) -> str:
"""Format a list of media items for the LLM to present.""" """Format a list of media items for the LLM to present.
Includes the TMDb ID so the LLM can reference it for follow-up actions."""
lines = [] lines = []
for i, item in enumerate(items[:10], 1): for i, item in enumerate(items[:10], 1):
title = item.get("title") or item.get("name") or "Unknown" title = item.get("title") or item.get("name") or "Unknown"
@@ -46,8 +47,10 @@ def _fmt_items(items: list[dict], kind: str) -> str:
or item.get("firstAirDate", "")[:4] or item.get("firstAirDate", "")[:4]
or "?" or "?"
) )
tmdb_id = item.get("id", "")
overview = (item.get("overview") or "")[:120] overview = (item.get("overview") or "")[:120]
lines.append(f"{i}. **{title}** ({year}) — {overview}") id_tag = f" [tmdb:{tmdb_id}]" if tmdb_id else ""
lines.append(f"{i}. **{title}** ({year}){id_tag}{overview}")
return f"Found {len(items)} {kind}. Top results:\n\n" + "\n".join(lines) return f"Found {len(items)} {kind}. Top results:\n\n" + "\n".join(lines)
@@ -160,6 +163,104 @@ TOOLS = [
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "seerr_search",
"description": "Search for movies, TV shows, or people on Seerr "
"by title or name. Uses /search. Call when a user asks 'find me "
"the movie X', 'search for show Y', or 'who is actor Z?'.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search term — a movie title, "
"TV show name, or person name.",
},
"kind": {
"type": "string",
"enum": ["movie", "tv", "person", "all"],
"description": "Filter by media type. Use 'all' "
"when the user doesn't specify.",
},
"language": {
"type": "string",
"description": "Language filter (e.g. 'en'). "
"Omit for all languages.",
},
"page": {
"type": "integer",
"description": "Page number (default 1).",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "seerr_media_details",
"description": "Get full details for a specific movie or TV show "
"(cast, crew, runtime, genres, ratings, streaming providers, etc.). "
"Call when a user asks 'tell me about movie X' or 'show me details "
"for show Y'.",
"parameters": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["movie", "tv"],
"description": "Whether to look up a movie or TV show.",
},
"tmdb_id": {
"type": "integer",
"description": "The TMDb ID of the movie or TV show.",
},
"title": {
"type": "string",
"description": "Title to search for if tmdb_id is "
"not known. The system will search and use the first match.",
},
"language": {
"type": "string",
"description": "Language filter (e.g. 'en'). "
"Omit for all languages.",
},
},
"required": ["kind"],
},
},
},
{
"type": "function",
"function": {
"name": "seerr_my_requests",
"description": "Get the user's pending, approved, or completed "
"media requests from Seerr. Call when a user asks 'what have I "
"requested?', 'status of my requests?', or 'did my request go through?'.",
"parameters": {
"type": "object",
"properties": {
"filter": {
"type": "string",
"enum": ["all", "approved", "available", "pending",
"processing", "unavailable", "failed",
"deleted", "completed"],
"description": "Filter by request status. "
"Default is 'pending'.",
},
"media_type": {
"type": "string",
"enum": ["movie", "tv", "all"],
"description": "Filter by media type. "
"Default is 'all'.",
},
},
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -218,6 +319,9 @@ async def _execute(tool_name: str, args: dict) -> ToolResult:
"seerr_discover": _discover, "seerr_discover": _discover,
"seerr_request_media": _request_media, "seerr_request_media": _request_media,
"seerr_submit_issue": _submit_issue, "seerr_submit_issue": _submit_issue,
"seerr_search": _search,
"seerr_media_details": _media_details,
"seerr_my_requests": _my_requests,
} }
handler = handlers.get(tool_name) handler = handlers.get(tool_name)
if not handler: if not handler:
@@ -277,9 +381,11 @@ async def _trending(args: dict) -> ToolResult:
async def _discover(args: dict) -> ToolResult: async def _discover(args: dict) -> ToolResult:
"""Jellyseerr discover endpoints: """Jellyseerr discover endpoints:
Genre: /api/v1/discover/{movies|tv}/genre/{genreId} Genre: /api/v1/discover/{movies|tv}/genre/{genreId}
Studio: /api/v1/discover/{movies|tv}/studio/{studioId} Keyword: /api/v1/search?query=keyword (free-text search, filtered by mediaType)
Keyword: /api/v1/discover/{movies|tv}?query=keyword Studio: NOT SUPPORTED — /discover/studio requires a numeric TMDB studio ID,
Language falls back to the base discover endpoint. not a name. Use keyword search as fallback.
Language is passed to discover or search as appropriate.
""" """
kind = args["kind"] kind = args["kind"]
genre = args.get("genre", "").strip() genre = args.get("genre", "").strip()
@@ -298,9 +404,8 @@ async def _discover(args: dict) -> ToolResult:
"war": 10752, "western": 37, "war": 10752, "western": 37,
} }
base = f"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}"
params: dict = {"page": page} params: dict = {"page": page}
endpoint = base endpoint: str
if genre: if genre:
genre_id = genre_map.get(genre.lower()) genre_id = genre_map.get(genre.lower())
@@ -309,22 +414,52 @@ async def _discover(args: dict) -> ToolResult:
f"I don't recognise the genre '{genre}'. " f"I don't recognise the genre '{genre}'. "
f"Try one of: {', '.join(sorted(genre_map.keys()))}." f"Try one of: {', '.join(sorted(genre_map.keys()))}."
) )
endpoint = f"{base}/genre/{genre_id}" endpoint = f"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}/genre/{genre_id}"
if language:
params["language"] = language
async with _client() as c:
r = await c.get(endpoint, params=params)
r.raise_for_status()
results = r.json().get("results", [])
desc = genre
elif studio: elif studio:
endpoint = f"{base}/studio/{studio}" # /discover/studio/{studioId} requires a numeric TMDB studio ID.
# Fall back to searching by name via /search.
desc = studio
search_query = studio
endpoint = "/api/v1/search"
params["query"] = search_query
if language:
params["language"] = language
async with _client() as c:
r = await c.get(endpoint, params=params)
r.raise_for_status()
results = r.json().get("results", [])
# Filter to requested media type
results = [item for item in results if item.get("mediaType") == kind]
elif keyword: elif keyword:
# Free-text keyword → use /search, filtered by mediaType
desc = keyword
endpoint = "/api/v1/search"
params["query"] = keyword params["query"] = keyword
endpoint = base if language:
params["language"] = language
async with _client() as c:
r = await c.get(endpoint, params=params)
r.raise_for_status()
results = r.json().get("results", [])
results = [item for item in results if item.get("mediaType") == kind]
else:
# Bare discover with no filter
endpoint = f"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}"
if language:
params["language"] = language
async with _client() as c:
r = await c.get(endpoint, params=params)
r.raise_for_status()
results = r.json().get("results", [])
desc = kind
if language:
params["language"] = language
async with _client() as c:
r = await c.get(endpoint, params=params)
r.raise_for_status()
results = r.json().get("results", [])
desc = genre or studio or keyword or kind
if not results: if not results:
return ToolResult.ok(f"No {desc} {kind}s found.") return ToolResult.ok(f"No {desc} {kind}s found.")
return ToolResult.ok(_fmt_items(results, f"{desc} {kind}s")) return ToolResult.ok(_fmt_items(results, f"{desc} {kind}s"))
@@ -335,16 +470,52 @@ async def _request_media(args: dict) -> ToolResult:
title = args["title"] title = args["title"]
tmdb_id = args.get("tmdb_id") tmdb_id = args.get("tmdb_id")
# Step 1: Search for the media
async with _client() as c: async with _client() as c:
r = await c.get("/api/v1/search/", params={"query": quote(title), "page": 1}) # --- Fast-path: TMDb ID known — confirm the title and request directly ---
if tmdb_id:
# Quick lookup to get the correct title for the confirmation message
detail_r = await c.get(f"/api/v1/{kind}/{tmdb_id}")
if detail_r.status_code == 200:
detail = detail_r.json()
media_title = detail.get("title") or detail.get("name") or title
media_year = (
detail.get("releaseDate", "")[:4]
or detail.get("firstAirDate", "")[:4]
or "?"
)
else:
# Detail lookup failed — fall back to title search
pass
if detail_r.status_code == 200:
# Submit directly with the known TMDb ID
request_body: dict = {"mediaType": kind, "mediaId": tmdb_id}
if kind == "tv":
request_body["seasons"] = "all"
req_r = await c.post("/api/v1/request", json=request_body)
if req_r.status_code == 201:
return ToolResult.ok(
f"✅ Successfully requested **{media_title}** ({media_year}). "
f"It has been submitted to Seerr and will be processed soon."
)
elif req_r.status_code == 409:
return ToolResult.fail(
f"⚠️ **{media_title}** ({media_year}) has already been "
f"requested or is already available."
)
else:
return ToolResult.fail(
f"❌ Failed to request **{media_title}** ({media_year}). "
f"Seerr responded with status {req_r.status_code}: {req_r.text[:500]}"
)
# --- Slow-path: search by title ---
r = await c.get("/api/v1/search", params={"query": quote(title), "page": 1})
r.raise_for_status() r.raise_for_status()
results = r.json().get("results", []) results = r.json().get("results", [])
# Filter by mediaType if we have results from unified search # Filter by mediaType (search returns mixed movie/tv/person results)
filtered = [item for item in results if item.get("mediaType") == kind] if results else [] filtered = [item for item in results if item.get("mediaType") == kind] if results else []
if not filtered:
filtered = results # fallback if mediaType not set
if not filtered: if not filtered:
return ToolResult.fail( return ToolResult.fail(
@@ -352,25 +523,35 @@ async def _request_media(args: dict) -> ToolResult:
f"Please double-check the title or provide a TMDb ID." f"Please double-check the title or provide a TMDb ID."
) )
# If tmdb_id provided, match it; otherwise use the first result # --- Ambiguity check: more than one match? ---
match = None if len(filtered) > 1:
if tmdb_id: lines = [
match = next( f"⚠️ Multiple matches for \"{title}\". "
(item for item in filtered if item.get("id") == tmdb_id), f"Please call `seerr_request_media` again with the "
None, f"correct `tmdb_id` and exact title:\n"
) ]
if not match: for i, item in enumerate(filtered[:10], 1):
match = filtered[0] t = item.get("title") or item.get("name", "Unknown")
y = (
item.get("releaseDate", "")[:4]
or item.get("firstAirDate", "")[:4]
or "?"
)
mid = item.get("id", "?")
lines.append(
f"{i}. **{t}** ({y}) — `kind=\"{kind}\", "
f"title=\"{t}\", tmdb_id={mid}`"
)
return ToolResult.ok("\n".join(lines))
# Seerr's request endpoint expects the local mediaInfo.id # --- Single match — request it ---
media_info = match.get("mediaInfo", {}) match = filtered[0]
media_id = media_info.get("id") or match.get("id") media_id = match.get("id")
media_title = match.get("title") or match.get("name") or title media_title = match.get("title") or match.get("name") or title
media_year = ( media_year = (
(match.get("releaseDate") or match.get("firstAirDate") or "?")[:4] (match.get("releaseDate") or match.get("firstAirDate") or "?")[:4]
) )
# Step 2: Submit the request
request_body = { request_body = {
"mediaType": kind, "mediaType": kind,
"mediaId": media_id, "mediaId": media_id,
@@ -398,6 +579,154 @@ async def _request_media(args: dict) -> ToolResult:
) )
async def _search(args: dict) -> ToolResult:
"""Use Jellyseerr's /api/v1/search endpoint.
Supports filtering by mediaType (movie | tv | person).
"""
query = args["query"]
kind = args.get("kind", "all")
language = args.get("language", "").strip() or None
page = args.get("page", 1)
params: dict = {"query": quote(query), "page": page}
if language:
params["language"] = language
async with _client() as c:
r = await c.get("/api/v1/search", params=params)
r.raise_for_status()
data = r.json()
results = data.get("results", [])
# Filter by mediaType if requested
if kind != "all":
results = [item for item in results if item.get("mediaType") == kind]
label = f"search results for '{query}'"
if kind != "all":
label += f" ({kind})"
if not results:
return ToolResult.ok(f"No {label} found.")
return ToolResult.ok(_fmt_items(results, label))
async def _media_details(args: dict) -> ToolResult:
"""Fetch full details for a movie or TV show.
Resolves the TMDb ID via search if not provided.
"""
kind = args["kind"]
tmdb_id = args.get("tmdb_id")
title = args.get("title", "").strip()
language = args.get("language", "").strip() or None
params: dict = {}
if language:
params["language"] = language
async with _client() as c:
# Resolve TMDb ID if needed
if not tmdb_id and title:
sr = await c.get("/api/v1/search", params={
"query": quote(title), "page": 1,
})
sr.raise_for_status()
sresults = sr.json().get("results", [])
sresults = [item for item in sresults if item.get("mediaType") == kind]
if sresults:
tmdb_id = sresults[0].get("id")
else:
return ToolResult.fail(
f"I couldn't find {kind} '{title}' on Seerr."
)
if not tmdb_id:
return ToolResult.fail(
"I need either a TMDb ID or a title to look up media details."
)
endpoint = f"/api/v1/{kind}/{tmdb_id}"
r = await c.get(endpoint, params=params)
r.raise_for_status()
data = r.json()
# Build a concise summary for the LLM
title_str = data.get("title") or data.get("name") or "Unknown"
year = (
data.get("releaseDate", "")[:4]
or data.get("firstAirDate", "")[:4]
or "?"
)
overview = data.get("overview", "No overview available.")
runtime = data.get("runtime", "?")
vote = data.get("voteAverage", "?")
genres = ", ".join(g.get("name", "") for g in data.get("genres", [])[:5])
lines = [
f"**{title_str}** ({year}) [tmdb:{tmdb_id}]",
f"{vote}/10 | ⏱ {runtime} min | Genres: {genres or 'N/A'}",
f"",
f"{overview[:500]}",
]
# Cast (top 5)
cast = (data.get("credits", {}) or {}).get("cast", [])[:5]
if cast:
lines.append("")
lines.append("**Top Cast:** " + ", ".join(
c["name"] for c in cast
))
# Streaming providers
providers = data.get("watchProviders", [])
if providers:
flatrate = []
for region in providers:
for p in region.get("flatrate", []) or []:
if p.get("name") and p["name"] not in flatrate:
flatrate.append(p["name"])
if flatrate:
lines.append("")
lines.append("**Streaming:** " + ", ".join(flatrate[:5]))
return ToolResult.ok("\n".join(lines))
async def _my_requests(args: dict) -> ToolResult:
"""Fetch the current user's media requests from /request.
Filters and sorting are optional.
"""
filter_status = args.get("filter", "pending")
media_type = args.get("media_type", "all")
params: dict = {"filter": filter_status}
if media_type != "all":
params["mediaType"] = media_type
async with _client() as c:
r = await c.get("/api/v1/request", params=params)
r.raise_for_status()
data = r.json()
results = data.get("results", [])
if not results:
return ToolResult.ok(f"You have no {filter_status} requests right now.")
lines = []
for i, req in enumerate(results[:10], 1):
media = req.get("media", {}) or {}
title = media.get("title") or media.get("name") or "Unknown"
status = req.get("status", "?")
status_labels = {1: "Pending", 2: "Approved", 3: "Declined"}
status_str = status_labels.get(status, f"Status {status}")
is_4k = " (4K)" if req.get("is4k") else ""
lines.append(f"{i}. **{title}**{is_4k}{status_str}")
total = data.get("pageInfo", {}).get("results", len(results))
return ToolResult.ok(
f"You have {total} {filter_status} requests:\n\n" + "\n".join(lines)
)
async def _submit_issue(args: dict) -> ToolResult: async def _submit_issue(args: dict) -> ToolResult:
subject = args["subject"] subject = args["subject"]
description = args["description"] description = args["description"]
@@ -407,23 +736,28 @@ async def _submit_issue(args: dict) -> ToolResult:
body: dict = { body: dict = {
"issueType": int(issue_type), "issueType": int(issue_type),
"subject": subject,
"message": description, "message": description,
} }
if media_title: if media_title:
body["message"] = f"[Media: {media_title}]\n\n{description}" body["message"] = f"[Media: {media_title}]\n\n{description}"
async with _client() as c: async with _client() as c:
# --- Resolve mediaId (Seerr's internal ID, not TMDb) --- # --- Resolve mediaId (Seerr internal ID for /issue endpoint) ---
if not media_id and media_title: if not media_id and media_title:
search_r = await c.get("/api/v1/search/", params={"query": quote(media_title), "page": 1}) search_r = await c.get("/api/v1/search", params={
if search_r.status_code == 200: "query": quote(media_title), "page": 1,
results = search_r.json().get("results", []) })
if results: search_r.raise_for_status()
# Seerr's /api/v1/issue expects the local mediaInfo.id, results = search_r.json().get("results", [])
# not the TMDb id at the top level.
media_info = results[0].get("mediaInfo", {}) # Filter to actual media (not persons) and prefer exact title match
media_id = media_info.get("id") or results[0].get("id") media_results = [
item for item in results
if item.get("mediaType") in ("movie", "tv")
]
if media_results:
media_info = media_results[0].get("mediaInfo", {})
media_id = media_info.get("id") or media_results[0].get("id")
if media_id: if media_id:
body["mediaId"] = int(media_id) body["mediaId"] = int(media_id)
@@ -449,18 +783,32 @@ async def _submit_issue(args: dict) -> ToolResult:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
seerr_skill = Skill( seerr_skill = Skill(
name="seerr", name="seerr",
description="Seerr integration — trending, discover, request media, submit issues", description="Seerr integration — search, trending, discover, request media, "
"look up details, check requests, submit issues",
prompt_fragment="""## Seerr Media Tools prompt_fragment="""## Seerr Media Tools
You have access to the Seerr media management system. Use the provided tools You have access to the Seerr media management system. Use the provided tools
to help users with media-related tasks: to help users with media-related tasks:
- **seerr_search** — when a user wants to find a specific movie, show, or person
- **seerr_trending** — when a user asks what is trending/popular/new - **seerr_trending** — when a user asks what is trending/popular/new
- **seerr_discover** — when a user asks for recommendations by genre/category - **seerr_discover** — when a user asks for recommendations by genre/category
- **seerr_media_details** — when a user wants full info about a movie or show
- **seerr_my_requests** — when a user asks about their pending/approved requests
- **seerr_request_media** — when a user wants to request a movie or TV show - **seerr_request_media** — when a user wants to request a movie or TV show
- **seerr_submit_issue** — when a user needs to report a problem or needs an - **seerr_submit_issue** — when a user needs to report a problem or needs an
operator-only action (like deleting media or cancelling a request) operator-only action (like deleting media or cancelling a request)
**TMDb ID Rule**: Every movie and TV show has a unique TMDb ID. When you see
`[tmdb:123456]` in search/trending/discover results, always **show it to the user**
in your response. Never strip or omit the TMDb ID when presenting results — the
user needs it to reference items for follow-up actions. Similarly, capture the ID
for any follow-up action you take (request details, submit a request, file an
issue, etc.). If you don't have a TMDb ID and need to take action on a title,
search first to get one. Never rely on title alone when an ID is available —
titles are ambiguous, IDs are not. This rule applies to all media tools, present
and future.
Always confirm successful actions to the user. If a tool fails, tell the user Always confirm successful actions to the user. If a tool fails, tell the user
what went wrong and suggest alternatives.""", what went wrong and suggest alternatives.""",
tools=TOOLS, tools=TOOLS,