Merge branch 'open-webui:dev' into dev

This commit is contained in:
Kevin Rohn 2026-02-03 16:12:13 +01:00 • committed by GitHub
commit 8ff00f6a03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2791 additions and 185 deletions

View file

@ -68,6 +68,7 @@ from open_webui.socket.main import (
get_models_in_use,
)
from open_webui.routers import (
analytics,
audio,
images,
ollama,
@ -1459,6 +1460,9 @@ app.include_router(functions.router, prefix="/api/v1/functions", tags=["function
app.include_router(
evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"]
)
app.include_router(
analytics.router, prefix="/api/v1/analytics", tags=["analytics"]
)
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
# SCIM 2.0 API for identity management

View file

@ -0,0 +1,173 @@
"""Add chat_message table
Revision ID: 8452d01d26d7
Revises: 374d2f66af06
Create Date: 2026-02-01 04:00:00.000000
"""
import time
import json
import logging
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
log = logging.getLogger(__name__)
revision: str = "8452d01d26d7"
down_revision: Union[str, None] = "374d2f66af06"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Step 1: Create table
op.create_table(
"chat_message",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("chat_id", sa.Text(), nullable=False, index=True),
sa.Column("user_id", sa.Text(), index=True),
sa.Column("role", sa.Text(), nullable=False),
sa.Column("parent_id", sa.Text(), nullable=True),
sa.Column("content", sa.JSON(), nullable=True),
sa.Column("output", sa.JSON(), nullable=True),
sa.Column("model_id", sa.Text(), nullable=True, index=True),
sa.Column("files", sa.JSON(), nullable=True),
sa.Column("sources", sa.JSON(), nullable=True),
sa.Column("embeds", sa.JSON(), nullable=True),
sa.Column("done", sa.Boolean(), default=True),
sa.Column("status_history", sa.JSON(), nullable=True),
sa.Column("error", sa.JSON(), nullable=True),
sa.Column("usage", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), index=True),
sa.Column("updated_at", sa.BigInteger()),
sa.ForeignKeyConstraint(["chat_id"], ["chat.id"], ondelete="CASCADE"),
)
# Create composite indexes
op.create_index(
"chat_message_chat_parent_idx", "chat_message", ["chat_id", "parent_id"]
)
op.create_index(
"chat_message_model_created_idx", "chat_message", ["model_id", "created_at"]
)
op.create_index(
"chat_message_user_created_idx", "chat_message", ["user_id", "created_at"]
)
# Step 2: Backfill from existing chats
conn = op.get_bind()
chat_table = sa.table(
"chat",
sa.column("id", sa.Text()),
sa.column("user_id", sa.Text()),
sa.column("chat", sa.JSON()),
)
chat_message_table = sa.table(
"chat_message",
sa.column("id", sa.Text()),
sa.column("chat_id", sa.Text()),
sa.column("user_id", sa.Text()),
sa.column("role", sa.Text()),
sa.column("parent_id", sa.Text()),
sa.column("content", sa.JSON()),
sa.column("output", sa.JSON()),
sa.column("model_id", sa.Text()),
sa.column("files", sa.JSON()),
sa.column("sources", sa.JSON()),
sa.column("embeds", sa.JSON()),
sa.column("done", sa.Boolean()),
sa.column("status_history", sa.JSON()),
sa.column("error", sa.JSON()),
sa.column("usage", sa.JSON()),
sa.column("created_at", sa.BigInteger()),
sa.column("updated_at", sa.BigInteger()),
)
# Fetch all chats
chats = conn.execute(
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat)
).fetchall()
now = int(time.time())
messages_inserted = 0
messages_failed = 0
for chat_row in chats:
chat_id = chat_row[0]
user_id = chat_row[1]
chat_data = chat_row[2]
if not chat_data:
continue
# Handle both string and dict chat data
if isinstance(chat_data, str):
try:
chat_data = json.loads(chat_data)
except Exception:
continue
history = chat_data.get("history", {})
messages = history.get("messages", {})
for message_id, message in messages.items():
if not isinstance(message, dict):
continue
role = message.get("role")
if not role:
continue
timestamp = message.get("timestamp", now)
# Normalize timestamp: convert ms to seconds, validate range
if timestamp > 10_000_000_000:
timestamp = timestamp // 1000
# Must be after 2020 and not too far in the future
if timestamp < 1577836800 or timestamp > now + 86400:
timestamp = now
# Use savepoint to allow individual insert failures without aborting transaction
savepoint = conn.begin_nested()
try:
conn.execute(
sa.insert(chat_message_table).values(
id=f"{chat_id}-{message_id}",
chat_id=chat_id,
user_id=user_id,
role=role,
parent_id=message.get("parentId"),
content=message.get("content"),
output=message.get("output"),
model_id=message.get("model"),
files=message.get("files"),
sources=message.get("sources"),
embeds=message.get("embeds"),
done=message.get("done", True),
status_history=message.get("statusHistory"),
error=message.get("error"),
created_at=timestamp,
updated_at=timestamp,
)
)
savepoint.commit()
messages_inserted += 1
except Exception as e:
savepoint.rollback()
messages_failed += 1
log.warning(f"Failed to insert message {message_id}: {e}")
continue
log.info(f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)")
def downgrade() -> None:
op.drop_index("chat_message_user_created_idx", table_name="chat_message")
op.drop_index("chat_message_model_created_idx", table_name="chat_message")
op.drop_index("chat_message_chat_parent_idx", table_name="chat_message")
op.drop_table("chat_message")

View file

@ -0,0 +1,545 @@
import json
import time
import uuid
from typing import Any, Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import (
BigInteger,
Boolean,
Column,
ForeignKey,
Text,
JSON,
Index,
)
####################
# Helpers
####################
def _normalize_timestamp(timestamp: int) -> float:
"""Normalize and validate timestamp. Returns current time if invalid."""
now = time.time()
# Convert milliseconds to seconds if needed
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000
# Validate: must be after 2020 and not in the future (with 1 day tolerance)
min_valid = 1577836800 # 2020-01-01 00:00:00 UTC
max_valid = now + 86400 # 1 day in the future (clock skew tolerance)
if timestamp < min_valid or timestamp > max_valid:
return now
return timestamp
####################
# ChatMessage DB Schema
####################
class ChatMessage(Base):
__tablename__ = "chat_message"
# Identity
id = Column(Text, primary_key=True)
chat_id = Column(
Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id = Column(Text, index=True)
# Structure
role = Column(Text, nullable=False) # user, assistant, system
parent_id = Column(Text, nullable=True)
# Content
content = Column(JSON, nullable=True) # Can be str or list of blocks
output = Column(JSON, nullable=True)
# Model (for assistant messages)
model_id = Column(Text, nullable=True, index=True)
# Attachments
files = Column(JSON, nullable=True)
sources = Column(JSON, nullable=True)
embeds = Column(JSON, nullable=True)
# Status
done = Column(Boolean, default=True)
status_history = Column(JSON, nullable=True)
error = Column(JSON, nullable=True)
# Usage (tokens, timing, etc.)
usage = Column(JSON, nullable=True)
# Timestamps
created_at = Column(BigInteger, index=True)
updated_at = Column(BigInteger)
__table_args__ = (
Index("chat_message_chat_parent_idx", "chat_id", "parent_id"),
Index("chat_message_model_created_idx", "model_id", "created_at"),
Index("chat_message_user_created_idx", "user_id", "created_at"),
)
####################
# Pydantic Models
####################
class ChatMessageModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
chat_id: str
user_id: str
role: str
parent_id: Optional[str] = None
content: Optional[Any] = None # str or list of blocks
output: Optional[list] = None
model_id: Optional[str] = None
files: Optional[list] = None
sources: Optional[list] = None
embeds: Optional[list] = None
done: bool = True
status_history: Optional[list] = None
error: Optional[dict] = None
usage: Optional[dict] = None
created_at: int
updated_at: int
####################
# Table Operations
####################
class ChatMessageTable:
def upsert_message(
self,
message_id: str,
chat_id: str,
user_id: str,
data: dict,
db: Optional[Session] = None,
) -> Optional[ChatMessageModel]:
"""Insert or update a chat message."""
with get_db_context(db) as db:
now = int(time.time())
timestamp = data.get("timestamp", now)
# Use composite ID: {chat_id}-{message_id}
composite_id = f"{chat_id}-{message_id}"
existing = db.get(ChatMessage, composite_id)
if existing:
# Update existing
if "role" in data:
existing.role = data["role"]
if "parent_id" in data:
existing.parent_id = data.get("parent_id") or data.get("parentId")
if "content" in data:
existing.content = data.get("content")
if "output" in data:
existing.output = data.get("output")
if "model_id" in data or "model" in data:
existing.model_id = data.get("model_id") or data.get("model")
if "files" in data:
existing.files = data.get("files")
if "sources" in data:
existing.sources = data.get("sources")
if "embeds" in data:
existing.embeds = data.get("embeds")
if "done" in data:
existing.done = data.get("done", True)
if "status_history" in data or "statusHistory" in data:
existing.status_history = data.get("status_history") or data.get(
"statusHistory"
)
if "error" in data:
existing.error = data.get("error")
# Extract usage - check direct field first, then info.usage
usage = data.get("usage")
if not usage:
info = data.get("info", {})
usage = info.get("usage") if info else None
if usage:
existing.usage = usage
existing.updated_at = now
db.commit()
db.refresh(existing)
return ChatMessageModel.model_validate(existing)
else:
# Insert new
# Extract usage - check direct field first, then info.usage
usage = data.get("usage")
if not usage:
info = data.get("info", {})
usage = info.get("usage") if info else None
message = ChatMessage(
id=composite_id,
chat_id=chat_id,
user_id=user_id,
role=data.get("role", "user"),
parent_id=data.get("parent_id") or data.get("parentId"),
content=data.get("content"),
output=data.get("output"),
model_id=data.get("model_id") or data.get("model"),
files=data.get("files"),
sources=data.get("sources"),
embeds=data.get("embeds"),
done=data.get("done", True),
status_history=data.get("status_history")
or data.get("statusHistory"),
error=data.get("error"),
usage=usage,
created_at=timestamp,
updated_at=now,
)
db.add(message)
db.commit()
db.refresh(message)
return ChatMessageModel.model_validate(message)
def get_message_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatMessageModel]:
with get_db_context(db) as db:
message = db.get(ChatMessage, id)
return ChatMessageModel.model_validate(message) if message else None
def get_messages_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
messages = (
db.query(ChatMessage)
.filter_by(chat_id=chat_id)
.order_by(ChatMessage.created_at.asc())
.all()
)
return [ChatMessageModel.model_validate(message) for message in messages]
def get_messages_by_user_id(
self,
user_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
messages = (
db.query(ChatMessage)
.filter_by(user_id=user_id)
.order_by(ChatMessage.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [ChatMessageModel.model_validate(message) for message in messages]
def get_messages_by_model_id(
self,
model_id: str,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
skip: int = 0,
limit: int = 100,
db: Optional[Session] = None,
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
query = db.query(ChatMessage).filter_by(model_id=model_id)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
messages = (
query.order_by(ChatMessage.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [ChatMessageModel.model_validate(message) for message in messages]
def delete_messages_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
db.query(ChatMessage).filter_by(chat_id=chat_id).delete()
db.commit()
return True
# Analytics methods
def get_message_count_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
query = db.query(
ChatMessage.model_id, func.count(ChatMessage.id).label("count")
).filter(ChatMessage.role == "assistant", ChatMessage.model_id.isnot(None))
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.group_by(ChatMessage.model_id).all()
return {row.model_id: row.count for row in results}
def get_token_usage_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, dict]:
"""Aggregate token usage by model using database-level aggregation."""
with get_db_context(db) as db:
from sqlalchemy import func, cast, Integer
dialect = db.bind.dialect.name
if dialect == "sqlite":
input_tokens = cast(
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
)
output_tokens = cast(
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
)
elif dialect == "postgresql":
# Use json_extract_path_text for PostgreSQL JSON columns
input_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, "input_tokens"), Integer
)
output_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, "output_tokens"), Integer
)
else:
raise NotImplementedError(f"Unsupported dialect: {dialect}")
query = db.query(
ChatMessage.model_id,
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
func.count(ChatMessage.id).label("message_count"),
).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
ChatMessage.usage.isnot(None),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.group_by(ChatMessage.model_id).all()
return {
row.model_id: {
"input_tokens": row.input_tokens,
"output_tokens": row.output_tokens,
"total_tokens": row.input_tokens + row.output_tokens,
"message_count": row.message_count,
}
for row in results
}
def get_token_usage_by_user(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, dict]:
"""Aggregate token usage by user using database-level aggregation."""
with get_db_context(db) as db:
from sqlalchemy import func, cast, Integer
dialect = db.bind.dialect.name
if dialect == "sqlite":
input_tokens = cast(
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
)
output_tokens = cast(
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
)
elif dialect == "postgresql":
# Use json_extract_path_text for PostgreSQL JSON columns
input_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, "input_tokens"), Integer
)
output_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, "output_tokens"), Integer
)
else:
raise NotImplementedError(f"Unsupported dialect: {dialect}")
query = db.query(
ChatMessage.user_id,
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
func.count(ChatMessage.id).label("message_count"),
).filter(
ChatMessage.role == "assistant",
ChatMessage.user_id.isnot(None),
ChatMessage.usage.isnot(None),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.group_by(ChatMessage.user_id).all()
return {
row.user_id: {
"input_tokens": row.input_tokens,
"output_tokens": row.output_tokens,
"total_tokens": row.input_tokens + row.output_tokens,
"message_count": row.message_count,
}
for row in results
}
def get_message_count_by_user(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
query = db.query(
ChatMessage.user_id, func.count(ChatMessage.id).label("count")
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.group_by(ChatMessage.user_id).all()
return {row.user_id: row.count for row in results}
def get_message_count_by_chat(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
query = db.query(
ChatMessage.chat_id, func.count(ChatMessage.id).label("count")
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.group_by(ChatMessage.chat_id).all()
return {row.chat_id: row.count for row in results}
def get_daily_message_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by day and model."""
with get_db_context(db) as db:
from datetime import datetime, timedelta
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None)
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.all()
# Group by date -> model -> count
daily_counts: dict[str, dict[str, int]] = {}
for timestamp, model_id in results:
date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime("%Y-%m-%d")
if date_str not in daily_counts:
daily_counts[date_str] = {}
daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1
# Fill in missing days
if start_date and end_date:
current = datetime.fromtimestamp(_normalize_timestamp(start_date))
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
while current <= end_dt:
date_str = current.strftime("%Y-%m-%d")
if date_str not in daily_counts:
daily_counts[date_str] = {}
current += timedelta(days=1)
return daily_counts
def get_hourly_message_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by hour and model."""
with get_db_context(db) as db:
from datetime import datetime, timedelta
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None)
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.all()
# Group by hour -> model -> count
hourly_counts: dict[str, dict[str, int]] = {}
for timestamp, model_id in results:
hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime("%Y-%m-%d %H:00")
if hour_str not in hourly_counts:
hourly_counts[hour_str] = {}
hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1
# Fill in missing hours
if start_date and end_date:
current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace(minute=0, second=0, microsecond=0)
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
while current <= end_dt:
hour_str = current.strftime("%Y-%m-%d %H:00")
if hour_str not in hourly_counts:
hourly_counts[hour_str] = {}
current += timedelta(hours=1)
return hourly_counts
ChatMessages = ChatMessageTable()

View file

@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.folders import Folders
from open_webui.models.chat_messages import ChatMessages
from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
from pydantic import BaseModel, ConfigDict
@ -314,6 +315,22 @@ class ChatTable:
db.add(chat_item)
db.commit()
db.refresh(chat_item)
# Dual-write initial messages to chat_message table
try:
history = form_data.chat.get("history", {})
messages = history.get("messages", {})
for message_id, message in messages.items():
if isinstance(message, dict) and message.get("role"):
ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=user_id,
data=message,
)
except Exception as e:
log.warning(f"Failed to write initial messages to chat_message table: {e}")
return ChatModel.model_validate(chat_item) if chat_item else None
def _chat_import_form_to_chat_model(
@ -356,6 +373,23 @@ class ChatTable:
db.add_all(chats)
db.commit()
# Dual-write messages to chat_message table
try:
for form_data, chat_obj in zip(chat_import_forms, chats):
history = form_data.chat.get("history", {})
messages = history.get("messages", {})
for message_id, message in messages.items():
if isinstance(message, dict) and message.get("role"):
ChatMessages.upsert_message(
message_id=message_id,
chat_id=chat_obj.id,
user_id=user_id,
data=message,
)
except Exception as e:
log.warning(f"Failed to write imported messages to chat_message table: {e}")
return [ChatModel.model_validate(chat) for chat in chats]
def update_chat_by_id(
@ -458,6 +492,18 @@ class ChatTable:
history["currentId"] = message_id
chat["history"] = history
# Dual-write to chat_message table
try:
ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=self.get_chat_by_id(id).user_id,
data=history["messages"][message_id],
)
except Exception as e:
log.warning(f"Failed to write to chat_message table: {e}")
return self.update_chat_by_id(id, chat)
def add_message_status_to_chat_by_id_and_message_id(

View file

@ -243,6 +243,7 @@ class UsersTable:
email: str,
profile_image_url: str = "/user.png",
role: str = "pending",
username: Optional[str] = None,
oauth: Optional[dict] = None,
db: Optional[Session] = None,
) -> Optional[UserModel]:
@ -257,6 +258,7 @@ class UsersTable:
"last_active_at": int(time.time()),
"created_at": int(time.time()),
"updated_at": int(time.time()),
"username": username,
"oauth": oauth,
}
)

View file

@ -0,0 +1,247 @@
from typing import Optional
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from open_webui.models.chat_messages import ChatMessages, ChatMessageModel
from open_webui.utils.auth import get_admin_user
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
router = APIRouter()
####################
# Response Models
####################
class ModelAnalyticsEntry(BaseModel):
model_id: str
count: int
class ModelAnalyticsResponse(BaseModel):
models: list[ModelAnalyticsEntry]
class UserAnalyticsEntry(BaseModel):
user_id: str
name: Optional[str] = None
email: Optional[str] = None
count: int
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
class UserAnalyticsResponse(BaseModel):
users: list[UserAnalyticsEntry]
####################
# Endpoints
####################
@router.get("/models", response_model=ModelAnalyticsResponse)
async def get_model_analytics(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts per model."""
counts = ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, db=db
)
models = [
ModelAnalyticsEntry(model_id=model_id, count=count)
for model_id, count in sorted(counts.items(), key=lambda x: -x[1])
]
return ModelAnalyticsResponse(models=models)
@router.get("/users", response_model=UserAnalyticsResponse)
async def get_user_analytics(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
limit: int = Query(50, description="Max users to return"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts and token usage per user with user info."""
from open_webui.models.users import Users
counts = ChatMessages.get_message_count_by_user(
start_date=start_date, end_date=end_date, db=db
)
token_usage = ChatMessages.get_token_usage_by_user(
start_date=start_date, end_date=end_date, db=db
)
# Get user info for top users
top_user_ids = [uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]]
user_info = {u.id: u for u in Users.get_users_by_user_ids(top_user_ids, db=db)}
users = []
for user_id in top_user_ids:
u = user_info.get(user_id)
tokens = token_usage.get(user_id, {})
users.append(UserAnalyticsEntry(
user_id=user_id,
name=u.name if u else None,
email=u.email if u else None,
count=counts[user_id],
input_tokens=tokens.get("input_tokens", 0),
output_tokens=tokens.get("output_tokens", 0),
total_tokens=tokens.get("total_tokens", 0),
))
return UserAnalyticsResponse(users=users)
@router.get("/messages", response_model=list[ChatMessageModel])
async def get_messages(
model_id: Optional[str] = Query(None, description="Filter by model ID"),
user_id: Optional[str] = Query(None, description="Filter by user ID"),
chat_id: Optional[str] = Query(None, description="Filter by chat ID"),
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
skip: int = Query(0),
limit: int = Query(50, le=100),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Query messages with filters."""
if chat_id:
return ChatMessages.get_messages_by_chat_id(chat_id=chat_id, db=db)
elif model_id:
return ChatMessages.get_messages_by_model_id(
model_id=model_id,
start_date=start_date,
end_date=end_date,
skip=skip,
limit=limit,
db=db,
)
elif user_id:
return ChatMessages.get_messages_by_user_id(
user_id=user_id, skip=skip, limit=limit, db=db
)
else:
# Return empty if no filter specified
return []
class SummaryResponse(BaseModel):
total_messages: int
total_chats: int
total_models: int
total_users: int
@router.get("/summary", response_model=SummaryResponse)
async def get_summary(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get summary statistics for the dashboard."""
model_counts = ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, db=db
)
user_counts = ChatMessages.get_message_count_by_user(
start_date=start_date, end_date=end_date, db=db
)
chat_counts = ChatMessages.get_message_count_by_chat(
start_date=start_date, end_date=end_date, db=db
)
return SummaryResponse(
total_messages=sum(model_counts.values()),
total_chats=len(chat_counts),
total_models=len(model_counts),
total_users=len(user_counts),
)
class DailyStatsEntry(BaseModel):
date: str
models: dict[str, int]
class DailyStatsResponse(BaseModel):
data: list[DailyStatsEntry]
@router.get("/daily", response_model=DailyStatsResponse)
async def get_daily_stats(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
granularity: str = Query("daily", description="Granularity: 'hourly' or 'daily'"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts grouped by model for time-series chart."""
if granularity == "hourly":
counts = ChatMessages.get_hourly_message_counts_by_model(
start_date=start_date, end_date=end_date, db=db
)
else:
counts = ChatMessages.get_daily_message_counts_by_model(
start_date=start_date, end_date=end_date, db=db
)
return DailyStatsResponse(
data=[
DailyStatsEntry(date=date, models=models)
for date, models in sorted(counts.items())
]
)
class TokenUsageEntry(BaseModel):
model_id: str
input_tokens: int
output_tokens: int
total_tokens: int
message_count: int
class TokenUsageResponse(BaseModel):
models: list[TokenUsageEntry]
total_input_tokens: int
total_output_tokens: int
total_tokens: int
@router.get("/tokens", response_model=TokenUsageResponse)
async def get_token_usage(
start_date: Optional[int] = Query(None),
end_date: Optional[int] = Query(None),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get token usage aggregated by model."""
usage = ChatMessages.get_token_usage_by_model(
start_date=start_date, end_date=end_date, db=db
)
models = [
TokenUsageEntry(model_id=model_id, **data)
for model_id, data in sorted(usage.items(), key=lambda x: -x[1]["total_tokens"])
]
total_input = sum(m.input_tokens for m in models)
total_output = sum(m.output_tokens for m in models)
return TokenUsageResponse(
models=models,
total_input_tokens=total_input,
total_output_tokens=total_output,
total_tokens=total_input + total_output,
)

View file

@ -794,6 +794,79 @@ def convert_to_azure_payload(url, payload: dict, api_version: str):
return url, payload
def convert_to_responses_payload(payload: dict) -> dict:
"""
Convert Chat Completions payload to Responses API format.
Chat Completions: { messages: [{role, content}], ... }
Responses API: { input: [{type: "message", role, content: [...]}], instructions: "system" }
"""
messages = payload.pop("messages", [])
system_content = ""
input_items = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
# Check for stored output items (from previous Responses API turn)
stored_output = msg.get("output")
if stored_output and isinstance(stored_output, list):
input_items.extend(stored_output)
continue
if role == "system":
if isinstance(content, str):
system_content = content
elif isinstance(content, list):
system_content = "\n".join(p.get("text", "") for p in content if p.get("type") == "text")
continue
# Convert content format
text_type = "output_text" if role == "assistant" else "input_text"
if isinstance(content, str):
content_parts = [{"type": text_type, "text": content}]
elif isinstance(content, list):
content_parts = []
for part in content:
if part.get("type") == "text":
content_parts.append({"type": text_type, "text": part.get("text", "")})
elif part.get("type") == "image_url":
url_data = part.get("image_url", {})
url = url_data.get("url", "") if isinstance(url_data, dict) else url_data
content_parts.append({"type": "input_image", "image_url": url})
else:
content_parts = [{"type": text_type, "text": str(content)}]
input_items.append({
"type": "message",
"role": role,
"content": content_parts
})
responses_payload = {**payload, "input": input_items}
if system_content:
responses_payload["instructions"] = system_content
if "max_tokens" in responses_payload:
responses_payload["max_output_tokens"] = responses_payload.pop("max_tokens")
return responses_payload
def convert_responses_result(response: dict) -> dict:
"""
Convert non-streaming Responses API result.
Just add done flag - pass through raw response, frontend handles output.
"""
response["done"] = True
return response
@router.post("/chat/completions")
async def generate_chat_completion(
request: Request,
@ -915,6 +988,8 @@ async def generate_chat_completion(
request, url, key, api_config, metadata, user=user
)
is_responses = api_config.get("api_type") == "responses"
if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview")
request_url, payload = convert_to_azure_payload(url, payload, api_version)
@ -925,9 +1000,18 @@ async def generate_chat_completion(
headers["api-key"] = key
headers["api-version"] = api_version
request_url = f"{request_url}/chat/completions?api-version={api_version}"
if is_responses:
payload = convert_to_responses_payload(payload)
request_url = f"{request_url}/responses?api-version={api_version}"
else:
request_url = f"{request_url}/chat/completions?api-version={api_version}"
else:
request_url = f"{url}/chat/completions"
if is_responses:
payload = convert_to_responses_payload(payload)
request_url = f"{url}/responses"
else:
request_url = f"{url}/chat/completions"
payload = json.dumps(payload)
@ -974,6 +1058,10 @@ async def generate_chat_completion(
else:
return PlainTextResponse(status_code=r.status, content=response)
# Convert Responses API result to simple format
if is_responses and isinstance(response, dict):
response = convert_responses_result(response)
return response
except Exception as e:
log.exception(e)

View file

@ -107,6 +107,7 @@ from open_webui.utils.filter import (
)
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.utils.payload import apply_system_prompt_to_body
from open_webui.utils.response import normalize_usage
from open_webui.utils.mcp.client import MCPClient
@ -293,6 +294,476 @@ def get_citation_source_from_tool_result(
]
def split_content_and_whitespace(content):
content_stripped = content.rstrip()
original_whitespace = (
content[len(content_stripped) :] if len(content) > len(content_stripped) else ""
)
return content_stripped, original_whitespace
def is_opening_code_block(content):
backtick_segments = content.split("```")
# Even number of segments means the last backticks are opening a new block
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
def serialize_output(output: list) -> str:
"""
Convert OR-aligned output items to HTML for display.
For LLM consumption, use convert_output_to_messages() instead.
"""
content = ""
# First pass: collect function_call_output items by call_id for lookup
tool_outputs = {}
for item in output:
if item.get("type") == "function_call_output":
tool_outputs[item.get("call_id")] = item
# Second pass: render items in order
for idx, item in enumerate(output):
item_type = item.get("type", "")
if item_type == "message":
for content_part in item.get("content", []):
if "text" in content_part:
text = content_part.get("text", "").strip()
if text:
content = f"{content}{text}\n"
elif item_type == "function_call":
# Render tool call inline with its result (if available)
if content and not content.endswith("\n"):
content += "\n"
call_id = item.get("call_id", "")
name = item.get("name", "")
arguments = item.get("arguments", "")
result_item = tool_outputs.get(call_id)
if result_item:
result_text = ""
for out in result_item.get("output", []):
if "text" in out:
result_text += out.get("text", "")
files = result_item.get("files")
embeds = result_item.get("embeds", "")
content += f'<details type="tool_calls" done="true" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}" result="{html.escape(json.dumps(result_text, ensure_ascii=False))}" files="{html.escape(json.dumps(files)) if files else ""}" embeds="{html.escape(json.dumps(embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
else:
content += f'<details type="tool_calls" done="false" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}">\n<summary>Executing...</summary>\n</details>\n'
elif item_type == "function_call_output":
# Already handled inline with function_call above
pass
elif item_type == "reasoning":
reasoning_content = ""
# Check for 'summary' (new structure) or 'content' (legacy/fallback)
source_list = item.get("summary", []) or item.get("content", [])
for content_part in source_list:
if "text" in content_part:
reasoning_content += content_part.get("text", "")
elif "summary" in content_part: # Handle potential nested logic if any
pass
reasoning_content = reasoning_content.strip()
duration = item.get("duration")
status = item.get("status", "in_progress")
# Infer completion: if this reasoning item is NOT the last item,
# render as done (a subsequent item means reasoning is complete)
is_last_item = idx == len(output) - 1
if content and not content.endswith("\n"):
content += "\n"
display = html.escape(
"\n".join(
(f"> {line}" if not line.startswith(">") else line)
for line in reasoning_content.splitlines()
)
)
if status == "completed" or duration is not None or not is_last_item:
content = f'{content}<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>\n'
else:
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
elif item_type == "open_webui:code_interpreter":
content_stripped, original_whitespace = split_content_and_whitespace(
content
)
if is_opening_code_block(content_stripped):
content = content_stripped.rstrip("`").rstrip() + original_whitespace
else:
content = content_stripped + original_whitespace
if content and not content.endswith("\n"):
content += "\n"
return content.strip()
def deep_merge(target, source):
"""
Merge source into target recursively (returning new structure).
- Dicts: Recursive merge.
- Strings: Concatenation.
- Others: Overwrite.
"""
if isinstance(target, dict) and isinstance(source, dict):
new_target = target.copy()
for k, v in source.items():
if k in new_target:
new_target[k] = deep_merge(new_target[k], v)
else:
new_target[k] = v
return new_target
elif isinstance(target, str) and isinstance(source, str):
return target + source
else:
return source
def handle_responses_streaming_event(
data: dict,
current_output: list,
) -> tuple[list, dict | None]:
"""
Handle Responses API streaming events in a pure functional way.
Args:
data: The event data
current_output: List of output items (treated as immutable)
Returns:
tuple[list, dict | None]: (new_output, metadata)
- new_output: The updated output list.
- metadata: Metadata to emit (e.g. usage), {} if update occurred, None if skip.
"""
# Default: no change
# Note: treating current_output as immutable, but avoiding full deepcopy for perf.
# We will shallow copy only if we need to modify the list structure or items.
event_type = data.get("type", "")
if event_type == "response.output_item.added":
item = data.get("item", {})
if item:
new_output = list(current_output)
new_output.append(item)
return new_output, None
return current_output, None
elif event_type == "response.content_part.added":
part = data.get("part", {})
output_index = data.get("output_index", len(current_output) - 1)
if current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
# Copy the item to mutate it
item = new_output[output_index].copy()
new_output[output_index] = item
if "content" not in item:
item["content"] = []
else:
# Copy content list
item["content"] = list(item["content"])
if item.get("type") == "reasoning":
# Reasoning items should not have content parts
pass
else:
item["content"].append(part)
item["content"].append(part)
return new_output, None
return current_output, None
elif event_type == "response.reasoning_summary_part.added":
part = data.get("part", {})
output_index = data.get("output_index", len(current_output) - 1)
if current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
if "summary" not in item:
item["summary"] = []
else:
item["summary"] = list(item["summary"])
item["summary"].append(part)
return new_output, None
return current_output, None
elif event_type.startswith("response.") and event_type.endswith(".delta"):
# Generic Delta Handling
parts = event_type.split(".")
if len(parts) >= 3:
delta_type = parts[1]
delta = data.get("delta", "")
output_index = data.get("output_index", len(current_output) - 1)
if current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
item_type = item.get("type", "")
# Determine target field and object based on delta_type and item_type
if delta_type == "function_call_arguments":
key = "arguments"
if item_type == "function_call":
# Function call args are usually strings
item[key] = item.get(key, "") + str(delta)
else:
# Generic handling, refined by item type below
pass
if item_type == "message":
# Message items: "text"/"output_text" -> "text"
# "reasoning_text" -> Skipped (should use reasoning item)
if delta_type in ["text", "output_text"]:
key = "text"
elif delta_type in ["reasoning_text", "reasoning_summary_text"]:
# Skip reasoning updates for message items
return new_output, None
else:
key = delta_type
content_index = data.get("content_index", 0)
if "content" not in item:
item["content"] = []
else:
item["content"] = list(item["content"])
content_list = item["content"]
while len(content_list) <= content_index:
content_list.append({"type": "text", "text": ""})
# Copy the part to mutate it
part = content_list[content_index].copy()
content_list[content_index] = part
current_val = part.get(key)
if current_val is None:
# Initialize based on delta type
current_val = {} if isinstance(delta, dict) else ""
part[key] = deep_merge(current_val, delta)
elif item_type == "reasoning":
# Reasoning items: "reasoning_text"/"reasoning_summary_text" -> "text"
# "text"/"output_text" -> Skipped (should use message item)
if delta_type == "reasoning_summary_text":
# Summary updates -> item['summary']
key = "text"
summary_index = data.get("summary_index", 0)
if "summary" not in item:
item["summary"] = []
else:
item["summary"] = list(item["summary"])
summary_list = item["summary"]
while len(summary_list) <= summary_index:
summary_list.append(
{"type": "summary_text", "text": ""}
)
part = summary_list[summary_index].copy()
summary_list[summary_index] = part
target_val = part.get(key, "")
part[key] = deep_merge(target_val, delta)
elif delta_type == "reasoning_text":
# Reasoning body updates -> item['content']
key = "text"
content_index = data.get("content_index", 0)
if "content" not in item:
item["content"] = []
else:
item["content"] = list(item["content"])
content_list = item["content"]
while len(content_list) <= content_index:
# Reasoning content parts default to text
content_list.append({"type": "text", "text": ""})
part = content_list[content_index].copy()
content_list[content_index] = part
target_val = part.get(key, "")
part[key] = deep_merge(target_val, delta)
elif delta_type in ["text", "output_text"]:
return new_output, None
else:
# Fallback just in case other deltas target reasoning?
pass
else:
# Fallback for other item types
if delta_type in ["text", "output_text"]:
key = "text"
else:
key = delta_type
current_val = item.get(key)
if current_val is None:
current_val = {} if isinstance(delta, dict) else ""
item[key] = deep_merge(current_val, delta)
return new_output, None
elif event_type.startswith("response.") and event_type.endswith(".done"):
# Delta Events: response.content_part.done, response.text.done, etc.
parts = event_type.split(".")
if len(parts) >= 3:
type_name = parts[1]
# 1. Handle specific Delta "done" signals
if type_name == "content_part":
# "Signaling that no further changes will occur to a content part"
# If payloads contains the full part, we could update it.
# Usually purely signaling in standard implementation, but we check payload.
part = data.get("part")
output_index = data.get("output_index", len(current_output) - 1)
if part and current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
if "content" in item:
item["content"] = list(item["content"])
content_index = data.get(
"content_index", len(item["content"]) - 1
)
if 0 <= content_index < len(item["content"]):
item["content"][content_index] = part
return new_output, {}
return current_output, None
elif type_name == "reasoning_summary_part":
part = data.get("part")
output_index = data.get("output_index", len(current_output) - 1)
if part and current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
if "summary" in item:
item["summary"] = list(item["summary"])
summary_index = data.get(
"summary_index", len(item["summary"]) - 1
)
if 0 <= summary_index < len(item["summary"]):
item["summary"][summary_index] = part
return new_output, {}
return current_output, None
# 2. Skip Output Item done (handled specifically below)
if type_name == "output_item":
pass
# 3. Generic Field Done (text.done, audio.done)
elif type_name not in ["completed", "failed"]:
output_index = data.get("output_index", len(current_output) - 1)
if current_output and 0 <= output_index < len(current_output):
key = (
"text"
if type_name
in [
"text",
"output_text",
"reasoning_text",
"reasoning_summary_text",
]
else type_name
)
if type_name == "function_call_arguments":
key = "arguments"
if key in data:
final_value = data[key]
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
item_type = item.get("type", "")
if type_name == "function_call_arguments":
if item_type == "function_call":
item["arguments"] = final_value
elif item_type == "message":
content_index = data.get("content_index", 0)
if "content" in item:
item["content"] = list(item["content"])
if len(item["content"]) > content_index:
part = item["content"][content_index].copy()
item["content"][content_index] = part
part[key] = final_value
elif item_type == "reasoning":
item["status"] = "completed"
else:
item[key] = final_value
return new_output, {}
return current_output, None
elif event_type == "response.output_item.done":
# Delta Event: Output item complete
item = data.get("item")
output_index = data.get("output_index", len(current_output) - 1)
new_output = list(current_output)
if item and 0 <= output_index < len(current_output):
new_output[output_index] = item
elif item:
new_output.append(item)
return new_output, {}
elif event_type == "response.completed":
# State Machine Event: Completed
response_data = data.get("response", {})
final_output = response_data.get("output")
new_output = final_output if final_output is not None else current_output
# Ensure reasoning items are marked as completed in the final output
if new_output:
for item in new_output:
if (
item.get("type") == "reasoning"
and item.get("status") != "completed"
):
item["status"] = "completed"
return new_output, {"usage": response_data.get("usage"), "done": True}
elif event_type == "response.in_progress":
# State Machine Event: In Progress
# We could extract metadata if needed, but for now just acknowledge iteration
return current_output, None
elif event_type == "response.failed":
# State Machine Event: Failed
error = data.get("response", {}).get("error", {})
return current_output, {"error": error}
else:
return current_output, None
def apply_source_context_to_messages(
request: Request,
messages: list,
@ -1570,7 +2041,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
raise e
try:
filter_ids = get_sorted_filter_ids(request, model, metadata.get("filter_ids", []))
filter_ids = get_sorted_filter_ids(
request, model, metadata.get("filter_ids", [])
)
filter_functions = Functions.get_functions_by_ids(filter_ids)
form_data, flags = await process_filter_functions(
@ -2367,20 +2840,6 @@ async def process_chat_response(
task_id = str(uuid4()) # Create a unique task ID.
model_id = form_data.get("model", "")
def split_content_and_whitespace(content):
content_stripped = content.rstrip()
original_whitespace = (
content[len(content_stripped) :]
if len(content) > len(content_stripped)
else ""
)
return content_stripped, original_whitespace
def is_opening_code_block(content):
backtick_segments = content.split("```")
# Even number of segments means the last backticks are opening a new block
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
# Handle as a background task
async def response_handler(response, events):
def serialize_content_blocks(content_blocks, raw=False):
@ -2518,105 +2977,6 @@ async def process_chat_response(
return content.strip()
def serialize_output(output: list) -> str:
"""
Convert OR-aligned output items to HTML for display.
For LLM consumption, use convert_output_to_messages() instead.
"""
content = ""
# First pass: collect function_call_output items by call_id for lookup
tool_outputs = {}
for item in output:
if item.get("type") == "function_call_output":
tool_outputs[item.get("call_id")] = item
# Second pass: render items in order
for item in output:
item_type = item.get("type", "")
if item_type == "message":
for content_part in item.get("content", []):
if content_part.get("type") == "output_text":
text = content_part.get("text", "").strip()
if text:
content = f"{content}{text}\n"
elif item_type == "function_call":
# Render tool call inline with its result (if available)
if content and not content.endswith("\n"):
content += "\n"
call_id = item.get("call_id", "")
name = item.get("name", "")
arguments = item.get("arguments", "")
result_item = tool_outputs.get(call_id)
if result_item:
result_text = ""
for out in result_item.get("output", []):
if out.get("type") == "input_text":
result_text += out.get("text", "")
files = result_item.get("files")
embeds = result_item.get("embeds", "")
content += f'<details type="tool_calls" done="true" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}" result="{html.escape(json.dumps(result_text, ensure_ascii=False))}" files="{html.escape(json.dumps(files)) if files else ""}" embeds="{html.escape(json.dumps(embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
else:
content += f'<details type="tool_calls" done="false" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}">\n<summary>Executing...</summary>\n</details>\n'
elif item_type == "function_call_output":
# Already handled inline with function_call above
pass
elif item_type == "reasoning":
reasoning_content = ""
for content_part in item.get("content", []):
if content_part.get("type") == "output_text":
reasoning_content = content_part.get("text", "").strip()
duration = item.get("duration")
status = item.get("status", "in_progress")
if content and not content.endswith("\n"):
content += "\n"
display = html.escape(
"\n".join(
(f"> {line}" if not line.startswith(">") else line)
for line in reasoning_content.splitlines()
)
)
if status == "completed" or duration is not None:
content = f'{content}<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>\n'
else:
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
elif item_type == "open_webui:code_interpreter":
code = item.get("code", "")
output_val = item.get("output")
lang = item.get("lang", "")
content_stripped, original_whitespace = (
split_content_and_whitespace(content)
)
if is_opening_code_block(content_stripped):
content = (
content_stripped.rstrip("`").rstrip()
+ original_whitespace
)
else:
content = content_stripped + original_whitespace
if content and not content.endswith("\n"):
content += "\n"
if output_val:
output_escaped = html.escape(json.dumps(output_val))
content = f'{content}<details type="code_interpreter" done="true" output="{output_escaped}">\n<summary>Analyzed</summary>\n```{lang}\n{code}\n```\n</details>\n'
else:
content = f'{content}<details type="code_interpreter" done="false">\n<summary>Analyzing...</summary>\n```{lang}\n{code}\n```\n</details>\n'
return content.strip()
def convert_content_blocks_to_messages(content_blocks, raw=False):
@ -2982,16 +3342,19 @@ async def process_chat_response(
if existing_output:
output = existing_output
else:
# Always create an initial message item (even if content is empty)
output = [
{
"type": "message",
"id": output_id("msg"),
"status": "in_progress",
"role": "assistant",
"content": [{"type": "output_text", "text": content}],
}
]
# Only create an initial message item if there is content to initialize with
if content:
output = [
{
"type": "message",
"id": output_id("msg"),
"status": "in_progress",
"role": "assistant",
"content": [{"type": "output_text", "text": content}],
}
]
else:
output = []
# Keep content_blocks for backward compatibility during transition
content_blocks = [
@ -3000,6 +3363,7 @@ async def process_chat_response(
"content": content,
}
]
usage = None
reasoning_tags_param = metadata.get("params", {}).get("reasoning_tags")
DETECT_REASONING_TAGS = reasoning_tags_param is not False
@ -3040,6 +3404,8 @@ async def process_chat_response(
async def stream_body_handler(response, form_data):
nonlocal content
nonlocal content_blocks
nonlocal usage
nonlocal output
response_tool_calls = []
@ -3118,13 +3484,39 @@ async def process_chat_response(
"data": data,
}
)
# Check for Responses API events (type field starts with "response.")
elif data.get("type", "").startswith("response."):
output, response_metadata = (
handle_responses_streaming_event(data, output)
)
processed_data = {
"output": output,
"content": serialize_output(output),
}
# print(data)
# print(processed_data)
# Merge any metadata (usage, done, etc.)
if response_metadata:
processed_data.update(response_metadata)
await event_emitter(
{
"type": "chat:completion",
"data": processed_data,
}
)
continue
else:
choices = data.get("choices", [])
# 17421
usage = data.get("usage", {}) or {}
usage.update(data.get("timings", {})) # llama.cpp
if usage:
# Normalize usage data to standard format
raw_usage = data.get("usage", {}) or {}
raw_usage.update(data.get("timings", {})) # llama.cpp
if raw_usage:
usage = normalize_usage(raw_usage)
await event_emitter(
{
"type": "chat:completion",
@ -3922,8 +4314,15 @@ async def process_chat_response(
{
"content": serialize_output(output),
"output": output,
**({"usage": usage} if usage else {}),
},
)
elif usage:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{"usage": usage},
)
# Send a webhook notification if the user is not active
if not Users.is_user_active(user.id):

View file

@ -6,6 +6,47 @@ from open_webui.utils.misc import (
)
def normalize_usage(usage: dict) -> dict:
"""
Normalize usage statistics to standard format.
Handles OpenAI, Ollama, and llama.cpp formats.
Adds standardized token fields to the original data:
- input_tokens: Number of tokens in the prompt
- output_tokens: Number of tokens generated
- total_tokens: Sum of input and output tokens
"""
if not usage:
return {}
# Map various field names to standard names
input_tokens = (
usage.get("input_tokens") # Already standard
or usage.get("prompt_tokens") # OpenAI
or usage.get("prompt_eval_count") # Ollama
or usage.get("prompt_n") # llama.cpp
or 0
)
output_tokens = (
usage.get("output_tokens") # Already standard
or usage.get("completion_tokens") # OpenAI
or usage.get("eval_count") # Ollama
or usage.get("predicted_n") # llama.cpp
or 0
)
total_tokens = usage.get("total_tokens") or (input_tokens + output_tokens)
# Add standardized fields to original data
result = dict(usage)
result["input_tokens"] = int(input_tokens)
result["output_tokens"] = int(output_tokens)
result["total_tokens"] = int(total_tokens)
return result
def convert_ollama_tool_call_to_openai(tool_calls: list) -> list:
openai_tool_calls = []
for tool_call in tool_calls:
@ -24,7 +65,19 @@ def convert_ollama_tool_call_to_openai(tool_calls: list) -> list:
def convert_ollama_usage_to_openai(data: dict) -> dict:
input_tokens = int(data.get("prompt_eval_count", 0))
output_tokens = int(data.get("eval_count", 0))
total_tokens = input_tokens + output_tokens
return {
# Standardized fields
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
# OpenAI-compatible fields (for backward compatibility)
"prompt_tokens": input_tokens,
"completion_tokens": output_tokens,
# Ollama-specific metrics
"response_token/s": (
round(
(
@ -56,22 +109,13 @@ def convert_ollama_usage_to_openai(data: dict) -> dict:
"total_duration": data.get("total_duration", 0),
"load_duration": data.get("load_duration", 0),
"prompt_eval_count": data.get("prompt_eval_count", 0),
"prompt_tokens": int(
data.get("prompt_eval_count", 0)
), # This is the OpenAI compatible key
"prompt_eval_duration": data.get("prompt_eval_duration", 0),
"eval_count": data.get("eval_count", 0),
"completion_tokens": int(
data.get("eval_count", 0)
), # This is the OpenAI compatible key
"eval_duration": data.get("eval_duration", 0),
"approximate_total": (lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s")(
(data.get("total_duration", 0) or 0) // 1_000_000_000
),
"total_tokens": int( # This is the OpenAI compatible key
data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
),
"completion_tokens_details": { # This is the OpenAI compatible key
"completion_tokens_details": {
"reasoning_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0,

View file

@ -21,7 +21,7 @@ dependencies = [
"authlib==1.6.6",
"requests==2.32.5",
"aiohttp==3.13.2" # do not update to 3.13.3 - broken,
"aiohttp==3.13.2", # do not update to 3.13.3 - broken
"async-timeout",
"aiocache",
"aiofiles",

View file

@ -0,0 +1,231 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const getModelAnalytics = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getUserAnalytics = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null,
limit: number = 50
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
if (limit) searchParams.append('limit', limit.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/users?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getMessages = async (
token: string = '',
modelId: string | null = null,
userId: string | null = null,
chatId: string | null = null,
startDate: number | null = null,
endDate: number | null = null,
skip: number = 0,
limit: number = 50
) => {
let error = null;
const searchParams = new URLSearchParams();
if (modelId) searchParams.append('model_id', modelId);
if (userId) searchParams.append('user_id', userId);
if (chatId) searchParams.append('chat_id', chatId);
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
if (skip) searchParams.append('skip', skip.toString());
if (limit) searchParams.append('limit', limit.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/messages?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getSummary = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/summary?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getDailyStats = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null,
granularity: 'hourly' | 'daily' = 'daily'
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
searchParams.append('granularity', granularity);
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getTokenUsage = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/tokens?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -42,6 +42,7 @@
let prefixId = '';
let enable = true;
let apiVersion = '';
let apiType = ''; // '' = chat completions (default), 'responses' = Responses API
let headers = '';
@ -183,7 +184,8 @@
connection_type: connectionType,
auth_type,
headers: headers ? JSON.parse(headers) : undefined,
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {})
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {}),
...(apiType ? { api_type: apiType } : {})
}
};
@ -221,6 +223,7 @@
connectionType = connection.config?.connection_type ?? 'external';
azure = connection.config?.azure ?? false;
apiVersion = connection.config?.api_version ?? '';
apiType = connection.config?.api_type ?? '';
}
}
};
@ -506,7 +509,7 @@
<div class="flex-1">
<input
id="api-version-input"
class={`w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={apiVersion}
placeholder={$i18n.t('API Version')}
@ -518,6 +521,45 @@
</div>
{/if}
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-1">
<label
for="api-type-toggle"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('API Type')}</label
>
<div>
<button
on:click={() => {
apiType = apiType === 'responses' ? '' : 'responses';
}}
type="button"
id="api-type-toggle"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if apiType === 'responses'}
<Tooltip
className="flex items-center gap-1"
content={$i18n.t(
'This feature is currently experimental and may not work as expected.'
)}
>
<span class=" text-gray-400 dark:text-gray-600"
>{$i18n.t('Experimental')}</span
>
{$i18n.t('Responses')}
</Tooltip>
{:else}
{$i18n.t('Chat Completions')}
{/if}
</button>
</div>
</div>
{/if}
<div class="flex flex-col w-full mt-2">
<div class="mb-1 flex justify-between">
<div

View file

@ -0,0 +1,24 @@
<script>
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
import Dashboard from './Analytics/Dashboard.svelte';
const i18n = getContext('i18n');
let loaded = false;
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
}
loaded = true;
});
</script>
{#if loaded}
<div class="w-full h-full pb-2 px-[16px]">
<Dashboard />
</div>
{/if}

View file

@ -0,0 +1,129 @@
<script lang="ts">
import dayjs from 'dayjs';
interface Props {
data: { date: string; models: Record<string, number> }[];
models: string[];
colors: string[];
height?: number;
period?: 'hour' | 'week' | 'month' | 'year' | 'all';
}
let { data, models, colors, height = 300, period = 'week' }: Props = $props();
let hoveredIdx: number | null = $state(null);
let mouseX = $state(0);
let colorMap = $derived(new Map(models.map((n, i) => [n, colors[i % colors.length]])));
let maxCount = $derived(Math.max(...data.flatMap((d) => Object.values(d.models || {})), 1));
const pad = { t: 8, r: 0, b: 20, l: 0 };
const w = 1000;
let cw = $derived(w - pad.l - pad.r);
let ch = $derived(height - pad.t - pad.b);
const getX = (i: number) =>
data.length <= 1 ? pad.l + cw / 2 : pad.l + (i / (data.length - 1)) * cw;
const getY = (v: number) => pad.t + ch - (v / maxCount) * ch;
const path = (m: string) => {
const pts = data.map((d, i) => `${getX(i)},${getY(d.models?.[m] || 0)}`);
return pts.length > 1 ? `M${pts.join('L')}` : '';
};
const onMove = (e: MouseEvent) => {
const svg = e.currentTarget as SVGSVGElement;
const r = svg.getBoundingClientRect();
mouseX = (e.clientX - r.left) * (w / r.width);
hoveredIdx = Math.max(
0,
Math.min(data.length - 1, Math.round(((mouseX - pad.l) / cw) * (data.length - 1)))
);
};
let hovered = $derived(hoveredIdx !== null ? data[hoveredIdx] : null);
</script>
<div class="relative w-full" style="height:{height}px">
<svg
viewBox="0 0 {w} {height - 20}"
class="h-[calc(100%-20px)] w-full"
preserveAspectRatio="none"
onmousemove={onMove}
onmouseleave={() => (hoveredIdx = null)}
>
{#each models as m}
<path
d={path(m)}
fill="none"
stroke={colorMap.get(m)}
stroke-width="1.5"
class={hovered && !hovered.models?.[m] ? 'opacity-20' : ''}
/>
{/each}
{#if hoveredIdx !== null}
<line
x1={getX(hoveredIdx)}
y1={pad.t}
x2={getX(hoveredIdx)}
y2={ch + pad.t}
stroke="#ddd"
stroke-width="1"
/>
{#each models as m}
{@const v = hovered?.models?.[m] || 0}
{#if v > 0}
<circle cx={getX(hoveredIdx)} cy={getY(v)} r="3" fill={colorMap.get(m)} />
{/if}
{/each}
{/if}
</svg>
<!-- X-axis labels as HTML -->
{#if data.length > 1}
{@const labelCount = Math.min(7, data.length)}
{@const step = labelCount > 1 ? Math.floor((data.length - 1) / (labelCount - 1)) || 1 : 1}
{@const isHourly = data[0]?.date?.includes(':')}
{@const dateFormat = isHourly ? 'h A' : period === 'year' || period === 'all' ? 'M/D/YY' : 'M/D'}
<div class="flex justify-between px-0.5 text-[10px] text-gray-400">
{#each Array(labelCount) as _, i}
{@const idx = i === labelCount - 1 ? data.length - 1 : Math.min(i * step, data.length - 1)}
{#if data[idx]}
<span class={i === 0 ? 'text-left' : i === labelCount - 1 ? 'text-right' : 'text-center'}
>{dayjs(data[idx].date).format(dateFormat)}</span
>
{/if}
{/each}
</div>
{/if}
{#if hovered}
{@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)}
<div
class="pointer-events-none absolute top-1 text-[11px]"
style="left:{Math.min(Math.max((mouseX / w) * 100, 8), 92)}%"
>
<div
class="min-w-[140px] -translate-x-1/2 rounded border border-gray-100 bg-white px-2.5 py-1.5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div class="mb-1.5 text-[10px] text-gray-400">
{#if hovered.date?.includes(':')}
{dayjs(hovered.date).format('MMM D, h A')}
{:else}
{dayjs(hovered.date).format('MMM D, YYYY')}
{/if}
</div>
{#each Object.entries(hovered.models || {})
.sort(([, a], [, b]) => b - a)
.slice(0, 5) as [n, c]}
<div class="flex items-center justify-between gap-2 py-0.5">
<span class="min-w-0 truncate text-gray-600 dark:text-gray-300">{n}</span>
<span class="shrink-0 text-gray-900 tabular-nums dark:text-white"
>{c.toLocaleString()}
<span class="text-gray-400">({total > 0 ? ((c / total) * 100).toFixed(0) : 0}%)</span
></span
>
</div>
{/each}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,346 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
import { getSummary, getModelAnalytics, getUserAnalytics, getDailyStats, getTokenUsage } from '$lib/apis/analytics';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChartLine from './ChartLine.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { formatNumber } from '$lib/utils';
const i18n = getContext('i18n');
// Time period
let selectedPeriod = '7d';
const periods = [
{ value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: '90d', label: 'Last 90 days' },
{ value: 'all', label: 'All time' }
];
const getDateRange = (period: string): { start: number | null; end: number | null } => {
const now = Math.floor(Date.now() / 1000);
const day = 86400;
switch (period) {
case '24h': return { start: now - day, end: now };
case '7d': return { start: now - 7 * day, end: now };
case '30d': return { start: now - 30 * day, end: now };
case '90d': return { start: now - 90 * day, end: now };
default: return { start: null, end: null };
}
};
// Data
let summary = { total_messages: 0, total_chats: 0, total_models: 0, total_users: 0 };
let modelStats: Array<{ model_id: string; count: number; name?: string }> = [];
let userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = [];
let dailyStats: Array<{ date: string; models: Record<string, number> }> = [];
let tokenStats: Record<string, { input_tokens: number; output_tokens: number; total_tokens: number }> = {};
let totalTokens = { input: 0, output: 0, total: 0 };
let loading = true;
// Sorting
let modelOrderBy = 'count';
let modelDirection: 'asc' | 'desc' = 'desc';
let userOrderBy = 'count';
let userDirection: 'asc' | 'desc' = 'desc';
const toggleModelSort = (key: string) => {
if (modelOrderBy === key) {
modelDirection = modelDirection === 'asc' ? 'desc' : 'asc';
} else {
modelOrderBy = key;
modelDirection = key === 'name' ? 'asc' : 'desc';
}
};
const toggleUserSort = (key: string) => {
if (userOrderBy === key) {
userDirection = userDirection === 'asc' ? 'desc' : 'asc';
} else {
userOrderBy = key;
userDirection = key === 'user_id' ? 'asc' : 'desc';
}
};
const loadDashboard = async () => {
loading = true;
try {
const { start, end } = getDateRange(selectedPeriod);
const granularity = selectedPeriod === '24h' ? 'hourly' : 'daily';
const [summaryRes, modelsRes, usersRes, dailyRes, tokensRes] = await Promise.all([
getSummary(localStorage.token, start, end),
getModelAnalytics(localStorage.token, start, end),
getUserAnalytics(localStorage.token, start, end, 50),
getDailyStats(localStorage.token, start, end, granularity),
getTokenUsage(localStorage.token, start, end)
]);
summary = summaryRes ?? summary;
const modelsMap = new Map($models.map((m) => [m.id, m.name || m.id]));
modelStats = (modelsRes?.models ?? []).map((entry) => ({
...entry,
name: modelsMap.get(entry.model_id) || entry.model_id
}));
userStats = usersRes?.users ?? [];
dailyStats = dailyRes?.data ?? [];
// Process token data
if (tokensRes) {
tokenStats = {};
for (const m of tokensRes.models) {
tokenStats[m.model_id] = {
input_tokens: m.input_tokens,
output_tokens: m.output_tokens,
total_tokens: m.total_tokens
};
}
totalTokens = {
input: tokensRes.total_input_tokens,
output: tokensRes.total_output_tokens,
total: tokensRes.total_tokens
};
}
} catch (err) {
console.error('Dashboard load failed:', err);
}
loading = false;
};
$: if (selectedPeriod) {
loadDashboard();
}
$: sortedModels = [...modelStats].sort((a, b) => {
if (modelOrderBy === 'name') {
return modelDirection === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name);
}
return modelDirection === 'asc' ? a.count - b.count : b.count - a.count;
});
$: sortedUsers = [...userStats].sort((a, b) => {
if (userOrderBy === 'name') {
const nameA = a.name || a.user_id;
const nameB = b.name || b.user_id;
return userDirection === 'asc'
? nameA.localeCompare(nameB)
: nameB.localeCompare(nameA);
}
return userDirection === 'asc' ? a.count - b.count : b.count - a.count;
});
$: totalModelMessages = modelStats.reduce((sum, m) => sum + m.count, 0);
onMount(loadDashboard);
</script>
<!-- Header with title and period selector -->
<div class="pt-0.5 pb-1 gap-1 flex flex-row justify-between items-center sticky top-0 z-10 bg-white dark:bg-gray-900">
<div class="text-lg font-medium px-0.5">
{$i18n.t('Analytics')}
</div>
<select
bind:value={selectedPeriod}
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
>
{#each periods as period}
<option value={period.value}>{$i18n.t(period.label)}</option>
{/each}
</select>
</div>
<!-- Summary stats -->
{#if !loading}
<div class="flex gap-3 text-xs text-gray-500 dark:text-gray-400 px-0.5 pb-2">
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_messages.toLocaleString()}</span> {$i18n.t('messages')}</span>
<span><span class="font-medium text-gray-900 dark:text-gray-300">{formatNumber(totalTokens.total)}</span> {$i18n.t('tokens')}</span>
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_chats.toLocaleString()}</span> {$i18n.t('chats')}</span>
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_users}</span> {$i18n.t('users')}</span>
</div>
<!-- Daily usage chart -->
{#if dailyStats.length > 1}
{@const allModels = [...new Set(dailyStats.flatMap(d => Object.keys(d.models || {})))]}
{@const topModels = allModels.slice(0, 8)}
{@const chartColors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']}
{@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', 'all': 'all' }}
<div class="mb-4">
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2 px-0.5">
{$i18n.t(selectedPeriod === '24h' ? 'Hourly Messages' : 'Daily Messages')}
</div>
<ChartLine
data={dailyStats}
models={topModels}
colors={chartColors}
height={200}
period={periodMap[selectedPeriod] || 'week'}
/>
</div>
{/if}
{/if}
{#if loading}
<div class="my-10 flex justify-center">
<Spinner className="size-5" />
</div>
{:else}
<div class="grid md:grid-cols-2 gap-4">
<!-- Model Usage Table -->
<div>
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5">
{$i18n.t('Model Usage')}
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th scope="col" class="px-2.5 py-2 w-8">#</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => toggleModelSort('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Model')}
{#if modelOrderBy === 'name'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('count')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Messages')}
{#if modelOrderBy === 'count'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
<th scope="col" class="px-2.5 py-2 text-right w-16">%</th>
</tr>
</thead>
<tbody>
{#each sortedModels as model, idx (model.model_id)}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
/>
<span class="truncate max-w-[150px]">{model.name}</span>
</div>
</td>
<td class="px-3 py-1 text-right">{model.count.toLocaleString()}</td>
<td class="px-3 py-1 text-right">{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td>
<td class="px-3 py-1 text-right text-gray-400">
{totalModelMessages > 0 ? ((model.count / totalModelMessages) * 100).toFixed(1) : 0}%
</td>
</tr>
{/each}
{#if sortedModels.length === 0}
<tr><td colspan="5" class="px-3 py-2 text-center text-gray-400">{$i18n.t('No data')}</td></tr>
{/if}
</tbody>
</table>
</div>
</div>
<!-- User Activity Table -->
<div>
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5">
{$i18n.t('User Activity')}
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th scope="col" class="px-2.5 py-2 w-8">#</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => toggleUserSort('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('User')}
{#if userOrderBy === 'name'}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleUserSort('count')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Messages')}
{#if userOrderBy === 'count'}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
</tr>
</thead>
<tbody>
{#each sortedUsers as user, idx (user.user_id)}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image"
alt={user.name || 'User'}
class="size-5 rounded-full object-cover shrink-0"
/>
<span class="truncate max-w-[150px]">{user.name || user.email || user.user_id.substring(0, 8)}</span>
</div>
</td>
<td class="px-3 py-1 text-right">{user.count.toLocaleString()}</td>
<td class="px-3 py-1 text-right">{formatNumber(user.total_tokens ?? 0)}</td>
</tr>
{/each}
{#if sortedUsers.length === 0}
<tr><td colspan="4" class="px-3 py-2 text-center text-gray-400">{$i18n.t('No data')}</td></tr>
{/if}
</tbody>
</table>
</div>
</div>
</div>
<div class="text-gray-500 text-xs mt-1.5 text-right">
ⓘ {$i18n.t('Message counts are based on assistant responses.')}
</div>
{/if}

View file

@ -0,0 +1,141 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
import { getModelAnalytics } from '$lib/apis/analytics';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
let modelStats: Array<{ model_id: string; count: number; name?: string }> = [];
let loading = true;
let orderBy = 'count';
let direction: 'asc' | 'desc' = 'desc';
const toggleSort = (key: string) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = key === 'name' ? 'asc' : 'desc';
}
};
const loadAnalytics = async () => {
loading = true;
try {
const result = await getModelAnalytics(localStorage.token);
const modelsMap = new Map($models.map((m) => [m.id, m.name || m.id]));
modelStats = (result?.models ?? []).map((entry) => ({
...entry,
name: modelsMap.get(entry.model_id) || entry.model_id
}));
} catch (err) {
console.error('Analytics load failed:', err);
}
loading = false;
};
$: sortedModels = [...modelStats].sort((a, b) => {
if (orderBy === 'name') {
return direction === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name);
}
return direction === 'asc' ? a.count - b.count : b.count - a.count;
});
$: totalMessages = modelStats.reduce((sum, m) => sum + m.count, 0);
onMount(loadAnalytics);
</script>
<div class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900">
<div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
{$i18n.t('Model Usage')}
<span class="text-lg text-gray-500">{totalMessages} {$i18n.t('messages')}</span>
</div>
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]">
{#if loading}
<div class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50">
<Spinner className="size-5" />
</div>
{/if}
{#if !modelStats.length && !loading}
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No data found')}</div>
{:else if modelStats.length}
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading ? 'opacity-20' : ''}">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th scope="col" class="px-2.5 py-2 w-8">#</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => toggleSort('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Model')}
{#if orderBy === 'name'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleSort('count')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Messages')}
{#if orderBy === 'count'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
</tr>
</thead>
<tbody>
{#each sortedModels as model, idx (model.model_id)}
<tr class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition">
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
{idx + 1}
</td>
<td class="px-3 py-1.5">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover"
/>
<span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
</div>
</td>
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
{model.count.toLocaleString()}
</td>
<td class="px-3 py-1.5 text-right font-medium text-blue-500">
{((model.count / totalMessages) * 100).toFixed(1)}%
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end">
<div class="text-right">
ⓘ {$i18n.t('Message counts are based on assistant responses.')}
</div>
</div>

View file

@ -0,0 +1,129 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { getUserAnalytics } from '$lib/apis/analytics';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
const i18n = getContext('i18n');
let userStats: Array<{ user_id: string; count: number }> = [];
let loading = true;
let orderBy = 'count';
let direction: 'asc' | 'desc' = 'desc';
const toggleSort = (key: string) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = key === 'user_id' ? 'asc' : 'desc';
}
};
const loadAnalytics = async () => {
loading = true;
try {
const result = await getUserAnalytics(localStorage.token, null, null, 100);
userStats = result?.users ?? [];
} catch (err) {
console.error('User analytics load failed:', err);
}
loading = false;
};
$: sortedUsers = [...userStats].sort((a, b) => {
if (orderBy === 'user_id') {
return direction === 'asc'
? a.user_id.localeCompare(b.user_id)
: b.user_id.localeCompare(a.user_id);
}
return direction === 'asc' ? a.count - b.count : b.count - a.count;
});
$: totalMessages = userStats.reduce((sum, u) => sum + u.count, 0);
onMount(loadAnalytics);
</script>
<div class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900">
<div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
{$i18n.t('User Activity')}
<span class="text-lg text-gray-500">{userStats.length} {$i18n.t('users')}</span>
</div>
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]">
{#if loading}
<div class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50">
<Spinner className="size-5" />
</div>
{/if}
{#if !userStats.length && !loading}
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No data found')}</div>
{:else if userStats.length}
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading ? 'opacity-20' : ''}">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th scope="col" class="px-2.5 py-2 w-8">#</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => toggleSort('user_id')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('User')}
{#if orderBy === 'user_id'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleSort('count')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Messages')}
{#if orderBy === 'count'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
</tr>
</thead>
<tbody>
{#each sortedUsers as user, idx (user.user_id)}
<tr class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition">
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
{idx + 1}
</td>
<td class="px-3 py-1.5">
<span class="font-medium text-gray-800 dark:text-gray-200 font-mono text-xs">
{user.user_id.substring(0, 8)}...
</span>
</td>
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
{user.count.toLocaleString()}
</td>
<td class="px-3 py-1.5 text-right font-medium text-blue-500">
{((user.count / totalMessages) * 100).toFixed(1)}%
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end">
<div class="text-right">
ⓘ {$i18n.t('Showing all messages (user + assistant) per user.')}
</div>
</div>

View file

@ -54,7 +54,9 @@
console.log('Exporting table to CSV');
// Extract header row text, decode HTML entities, and escape for CSV.
const header = token.header.map((headerCell) => `"${decode(headerCell.text).replace(/"/g, '""')}"`);
const header = token.header.map(
(headerCell) => `"${decode(headerCell.text).replace(/"/g, '""')}"`
);
// Create an array for rows that will hold the mapped cell text.
const rows = token.rows.map((row) =>
@ -316,26 +318,41 @@
</ul>
{/if}
{:else if token.type === 'details'}
<Collapsible
title={token.summary}
open={$settings?.expandDetails ?? false}
attributes={token?.attributes}
className="w-full space-y-1"
dir="auto"
>
<div class=" mb-1.5" slot="content">
<svelte:self
id={`${id}-${tokenIdx}-d`}
tokens={marked.lexer(decode(token.text))}
attributes={token?.attributes}
{done}
{editCodeBlock}
{onTaskClick}
{sourceIds}
{onSourceClick}
/>
</div>
</Collapsible>
{@const textContent = decode(token.text || '')
.replace(/<summary>.*?<\/summary>/gi, '')
.trim()}
{#if textContent.length > 0}
<Collapsible
title={token.summary}
open={$settings?.expandDetails ?? false}
attributes={token?.attributes}
className="w-full space-y-1"
dir="auto"
>
<div class=" mb-1.5" slot="content">
<svelte:self
id={`${id}-${tokenIdx}-d`}
tokens={marked.lexer(decode(token.text))}
attributes={token?.attributes}
{done}
{editCodeBlock}
{onTaskClick}
{sourceIds}
{onSourceClick}
/>
</div>
</Collapsible>
{:else}
<Collapsible
title={token.summary}
open={false}
disabled={true}
attributes={token?.attributes}
className="w-full space-y-1"
dir="auto"
/>
{/if}
{:else if token.type === 'html'}
<HtmlToken {id} {token} {onSourceClick} />
{:else if token.type === 'iframe'}

View file

@ -222,7 +222,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
class="{buttonClassName} {disabled ? '' : 'cursor-pointer'}"
on:pointerup={() => {
if (!disabled) {
open = !open;
@ -270,13 +270,15 @@
{/if}
</div>
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
{#if !disabled}
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
{/if}
</div>
</div>
{:else}

View file

@ -28,6 +28,10 @@ import hljs from 'highlight.js';
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const formatNumber = (num: number): string => {
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(num);
};
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View file

@ -66,12 +66,12 @@
href="/admin">{$i18n.t('Users')}</a
>
<!-- <a
<a
class="min-w-fit p-1.5 {$page.url.pathname.includes('/admin/analytics')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/admin/analytics">{$i18n.t('Analytics')}</a
> -->
>
<a
class="min-w-fit p-1.5 {$page.url.pathname.includes('/admin/evaluations')

View file

@ -1,12 +1,5 @@
<script>
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Evaluations from '$lib/components/admin/Evaluations.svelte';
onMount(() => {
goto('/admin/evaluations/leaderboard');
});
import Analytics from '$lib/components/admin/Analytics.svelte';
</script>
<Evaluations />
<Analytics />

View file

@ -1,5 +1,5 @@
<script>
import Evaluations from '$lib/components/admin/Evaluations.svelte';
import Analytics from '$lib/components/admin/Analytics.svelte';
</script>
<Evaluations />
<Analytics />