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
Build and Push Agent API / build (push) Successful in 14s
This commit is contained in:
@@ -174,3 +174,4 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
.docs/
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user