feat:精简
Some checks failed
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Python CI / Format Backend (3.11.x) (push) Has been cancelled
Python CI / Format Backend (3.12.x) (push) Has been cancelled
Frontend Build / Format & Build Frontend (push) Has been cancelled
Frontend Build / Frontend Unit Tests (push) Has been cancelled
Create and publish Docker images with specific build args / merge-main-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda126-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-ollama-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-slim-images (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled

This commit is contained in:
2026-01-16 18:34:38 +08:00
parent 16263710d9
commit 11fcec9387
137 changed files with 68993 additions and 6435 deletions

View File

@@ -72,6 +72,7 @@ class SignupForm(BaseModel):
email: str
password: str
profile_image_url: Optional[str] = "/user.png"
email_code: Optional[str] = None
class AddUserForm(SignupForm):

View File

@@ -1,57 +0,0 @@
# credits model - deprecated, kept for backward compatibility
# this module is replaced by the subscription system
from decimal import Decimal
from typing import Optional, List
from pydantic import BaseModel
class SetCreditFormDetail(BaseModel):
operator: str = ""
api_name: str = ""
api_params: dict = {}
class SetCreditForm(BaseModel):
user_id: str
credit: Decimal = Decimal("0")
detail: Optional[SetCreditFormDetail] = None
class AddCreditForm(BaseModel):
user_id: str
amount: Decimal = Decimal("0")
detail: Optional[SetCreditFormDetail] = None
class CreditModel(BaseModel):
user_id: str
credit: Decimal = Decimal("0")
class Credits:
"""
Deprecated credits class.
Kept for backward compatibility - returns dummy data.
The subscription system now handles billing.
"""
@classmethod
def init_credit_by_user_id(cls, user_id: str) -> CreditModel:
return CreditModel(user_id=user_id, credit=Decimal("0"))
@classmethod
def get_credit_by_user_id(cls, user_id: str) -> Optional[CreditModel]:
return CreditModel(user_id=user_id, credit=Decimal("0"))
@classmethod
def set_credit_by_user_id(cls, form_data: SetCreditForm) -> CreditModel:
return CreditModel(user_id=form_data.user_id, credit=form_data.credit)
@classmethod
def add_credit_by_user_id(cls, form_data: AddCreditForm) -> CreditModel:
return CreditModel(user_id=form_data.user_id, credit=form_data.amount)
@classmethod
def list_credits_by_user_id(cls, user_ids: List[str]) -> List[CreditModel]:
return [CreditModel(user_id=uid, credit=Decimal("0")) for uid in user_ids]

View File

@@ -1,394 +0,0 @@
import json
import time
import uuid
from typing import Optional
from functools import lru_cache
from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access
from open_webui.models.users import User, UserModel, Users, UserResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
from sqlalchemy.sql import exists
####################
# Note DB Schema
####################
class Note(Base):
__tablename__ = "note"
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text)
title = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class NoteModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
title: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
####################
# Forms
####################
class NoteForm(BaseModel):
title: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
class NoteUpdateForm(BaseModel):
title: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
class NoteUserResponse(NoteModel):
user: Optional[UserResponse] = None
class NoteItemResponse(BaseModel):
id: str
title: str
data: Optional[dict]
updated_at: int
created_at: int
user: Optional[UserResponse] = None
class NoteListResponse(BaseModel):
items: list[NoteUserResponse]
total: int
class NoteTable:
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
conditions = []
# Handle read_only permission separately
if permission == "read_only":
# For read_only, we want items where:
# 1. User has explicit read permission (via groups or user-level)
# 2. BUT does NOT have write permission
# 3. Public items are NOT considered read_only
read_conditions = []
# Group-level read permission
if group_ids:
group_read_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_read_conditions.append(
Note.access_control["read"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_read_conditions.append(
cast(
Note.access_control["read"]["group_ids"],
JSONB,
).contains([gid])
)
if group_read_conditions:
read_conditions.append(or_(*group_read_conditions))
# Combine read conditions
if read_conditions:
has_read = or_(*read_conditions)
else:
# If no read conditions, return empty result
return query.filter(False)
# Now exclude items where user has write permission
write_exclusions = []
# Exclude items owned by user (they have implicit write)
if user_id:
write_exclusions.append(Note.user_id != user_id)
# Exclude items where user has explicit write permission via groups
if group_ids:
group_write_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_write_conditions.append(
Note.access_control["write"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_write_conditions.append(
cast(
Note.access_control["write"]["group_ids"],
JSONB,
).contains([gid])
)
if group_write_conditions:
# User should NOT have write permission
write_exclusions.append(~or_(*group_write_conditions))
# Exclude public items (items without access_control)
write_exclusions.append(Note.access_control.isnot(None))
write_exclusions.append(cast(Note.access_control, String) != "null")
# Combine: has read AND does not have write AND not public
if write_exclusions:
query = query.filter(and_(has_read, *write_exclusions))
else:
query = query.filter(has_read)
return query
# Original logic for other permissions (read, write, etc.)
# Public access conditions
if group_ids or user_id:
conditions.extend(
[
Note.access_control.is_(None),
cast(Note.access_control, String) == "null",
]
)
# User-level permission (owner has all permissions)
if user_id:
conditions.append(Note.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Note.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Note.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
def insert_new_note(
self,
form_data: NoteForm,
user_id: str,
) -> Optional[NoteModel]:
with get_db() as db:
note = NoteModel(
**{
"id": str(uuid.uuid4()),
"user_id": user_id,
**form_data.model_dump(),
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
}
)
new_note = Note(**note.model_dump())
db.add(new_note)
db.commit()
return note
def get_notes(
self, skip: Optional[int] = None, limit: Optional[int] = None
) -> list[NoteModel]:
with get_db() as db:
query = db.query(Note).order_by(Note.updated_at.desc())
if skip is not None:
query = query.offset(skip)
if limit is not None:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
def search_notes(
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
) -> NoteListResponse:
with get_db() as db:
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Note.title.ilike(f"%{query_key}%"),
cast(Note.data["content"]["md"], Text).ilike(
f"%{query_key}%"
),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Note.user_id == user_id)
elif view_option == "shared":
query = query.filter(Note.user_id != user_id)
# Apply access control filtering
if "permission" in filter:
permission = filter["permission"]
else:
permission = "write"
query = self._has_permission(
db,
query,
filter,
permission=permission,
)
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(Note.title.asc())
else:
query = query.order_by(Note.title.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Note.created_at.asc())
else:
query = query.order_by(Note.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Note.updated_at.asc())
else:
query = query.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
notes = []
for note, user in items:
notes.append(
NoteUserResponse(
**NoteModel.model_validate(note).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
)
)
return NoteListResponse(items=notes, total=total)
def get_notes_by_user_id(
self,
user_id: str,
permission: str = "read",
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[NoteModel]:
with get_db() as db:
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id)
]
query = db.query(Note).order_by(Note.updated_at.desc())
query = self._has_permission(
db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission
)
if skip is not None:
query = query.offset(skip)
if limit is not None:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db:
note = db.query(Note).filter(Note.id == id).first()
return NoteModel.model_validate(note) if note else None
def update_note_by_id(
self, id: str, form_data: NoteUpdateForm
) -> Optional[NoteModel]:
with get_db() as db:
note = db.query(Note).filter(Note.id == id).first()
if not note:
return None
form_data = form_data.model_dump(exclude_unset=True)
if "title" in form_data:
note.title = form_data["title"]
if "data" in form_data:
note.data = {**note.data, **form_data["data"]}
if "meta" in form_data:
note.meta = {**note.meta, **form_data["meta"]}
if "access_control" in form_data:
note.access_control = form_data["access_control"]
note.updated_at = int(time.time_ns())
db.commit()
return NoteModel.model_validate(note) if note else None
def delete_note_by_id(self, id: str):
with get_db() as db:
db.query(Note).filter(Note.id == id).delete()
db.commit()
return True
Notes = NoteTable()

View File

@@ -1,9 +1,10 @@
import json
import time
import uuid
from typing import List, Optional, Tuple
from typing import Dict, List, Optional, Tuple
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import JSON, BigInteger, Boolean, Column, Integer, Numeric, String, Text
from open_webui.internal.db import Base, get_db
@@ -21,8 +22,19 @@ class SubscriptionPlan(Base):
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
price = Column(Numeric(precision=10, scale=2), default=0)
# per-model monthly limits: {"model_id": limit, ...}
# -1 = unlimited, 0 = not allowed, positive = monthly limit
model_limits = Column(JSON, nullable=True)
# default limit for models not in model_limits
# -1 = unlimited, 0 = not allowed, positive = monthly limit
default_model_limit = Column(Integer, default=0)
# deprecated fields (kept for backward compatibility)
monthly_message_limit = Column(Integer, nullable=True)
allowed_models = Column(JSON, nullable=True)
priority = Column(Integer, default=0)
is_default = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
@@ -42,6 +54,11 @@ class UserSubscription(Base):
current_period_start = Column(BigInteger)
current_period_end = Column(BigInteger)
# per-model usage tracking: {"model_id": count, ...}
model_usage = Column(JSON, default={})
# deprecated field (kept for backward compatibility)
messages_used = Column(Integer, default=0)
created_at = Column(BigInteger)
@@ -72,7 +89,7 @@ class RedemptionCode(Base):
redemption_type = Column(String, default="duration")
plan_id = Column(String, nullable=True)
duration_days = Column(Integer, nullable=True)
upgrade_expires_at = Column(BigInteger, nullable=True)
upgrade_days = Column(Integer, nullable=True)
user_id = Column(String, index=True, nullable=True)
created_at = Column(BigInteger, index=True)
@@ -85,6 +102,16 @@ class RedemptionCode(Base):
####################
def _parse_json_field(v):
"""Parse JSON field from string if needed."""
if isinstance(v, str):
try:
return json.loads(v)
except json.JSONDecodeError:
return None
return v
class SubscriptionPlanModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -92,22 +119,43 @@ class SubscriptionPlanModel(BaseModel):
name: str
description: Optional[str] = None
price: Decimal = Field(default_factory=lambda: Decimal("0"))
# per-model monthly limits
model_limits: Optional[Dict[str, int]] = None
default_model_limit: int = 0
# deprecated fields
monthly_message_limit: Optional[int] = None
allowed_models: Optional[List[str]] = None
priority: int = 0
is_default: bool = False
is_active: bool = True
created_at: int = Field(default_factory=lambda: int(time.time()))
updated_at: int = Field(default_factory=lambda: int(time.time()))
@field_validator('model_limits', 'allowed_models', mode='before')
@classmethod
def parse_json_fields(cls, v):
return _parse_json_field(v)
def get_model_limit(self, model_id: str) -> int:
"""
Get the usage limit for a specific model.
Returns: -1 = unlimited, 0 = not allowed, positive = monthly limit
"""
if self.model_limits and model_id in self.model_limits:
return self.model_limits[model_id]
return self.default_model_limit
class SubscriptionPlanForm(BaseModel):
id: str
name: str
description: Optional[str] = None
price: float = 0
monthly_message_limit: Optional[int] = None
allowed_models: Optional[List[str]] = None
model_limits: Optional[Dict[str, int]] = None
default_model_limit: int = 0
priority: int = 0
is_default: bool = False
is_active: bool = True
@@ -117,8 +165,8 @@ class UpdateSubscriptionPlanForm(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
monthly_message_limit: Optional[int] = None
allowed_models: Optional[List[str]] = None
model_limits: Optional[Dict[str, int]] = None
default_model_limit: Optional[int] = None
priority: Optional[int] = None
is_default: Optional[bool] = None
is_active: Optional[bool] = None
@@ -134,16 +182,48 @@ class UserSubscriptionModel(BaseModel):
expires_at: Optional[int] = None
current_period_start: int
current_period_end: int
# per-model usage tracking
model_usage: Dict[str, int] = Field(default_factory=dict)
# deprecated field
messages_used: int = 0
created_at: int = Field(default_factory=lambda: int(time.time()))
updated_at: int = Field(default_factory=lambda: int(time.time()))
@field_validator('model_usage', mode='before')
@classmethod
def parse_model_usage(cls, v):
parsed = _parse_json_field(v)
return parsed if parsed else {}
def get_model_usage(self, model_id: str) -> int:
"""Get usage count for a specific model."""
return self.model_usage.get(model_id, 0)
class ModelUsageInfo(BaseModel):
"""Usage info for a single model."""
model_id: str
limit: int # -1 = unlimited, 0 = not allowed
used: int
remaining: Optional[int] = None # None if unlimited
class UserSubscriptionWithPlanModel(UserSubscriptionModel):
plan: Optional[SubscriptionPlanModel] = None
messages_remaining: Optional[int] = None
days_remaining: Optional[int] = None
# per-model usage summary
model_usage_info: Optional[List[ModelUsageInfo]] = None
# deprecated
messages_remaining: Optional[int] = None
# admin view fields
username: Optional[str] = None
class SubscriptionUsageLogModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -166,7 +246,7 @@ class RedemptionCodeModel(BaseModel):
redemption_type: str = "duration"
plan_id: Optional[str] = None
duration_days: Optional[int] = None
upgrade_expires_at: Optional[int] = None
upgrade_days: Optional[int] = None
user_id: Optional[str] = None
created_at: int
expired_at: Optional[int] = None
@@ -179,7 +259,7 @@ class CreateRedemptionCodeForm(BaseModel):
redemption_type: str = "duration"
plan_id: str
duration_days: Optional[int] = Field(default=None, ge=1)
upgrade_expires_at: Optional[int] = Field(default=None, gt=0)
upgrade_days: Optional[int] = Field(default=None, ge=1)
expired_at: Optional[int] = Field(default=None, gt=0)
@@ -187,7 +267,7 @@ class UpdateRedemptionCodeForm(BaseModel):
purpose: Optional[str] = Field(None, min_length=1, max_length=255)
plan_id: Optional[str] = None
duration_days: Optional[int] = Field(None, ge=1)
upgrade_expires_at: Optional[int] = Field(None, gt=0)
upgrade_days: Optional[int] = Field(None, ge=1)
expired_at: Optional[int] = Field(None, gt=0)
@@ -231,8 +311,8 @@ class SubscriptionPlansTable:
name=form_data.name,
description=form_data.description,
price=Decimal(str(form_data.price)),
monthly_message_limit=form_data.monthly_message_limit,
allowed_models=form_data.allowed_models,
model_limits=form_data.model_limits,
default_model_limit=form_data.default_model_limit,
priority=form_data.priority,
is_default=form_data.is_default,
is_active=form_data.is_active,
@@ -302,19 +382,28 @@ class UserSubscriptionsTable:
plan = SubscriptionPlans.get_plan_by_id(sub.plan_id)
now = int(time.time())
messages_remaining = None
if plan and plan.monthly_message_limit:
messages_remaining = max(0, plan.monthly_message_limit - sub.messages_used)
days_remaining = None
if sub.expires_at:
days_remaining = max(0, (sub.expires_at - now) // 86400)
# build model usage info
model_usage_info = []
if plan and plan.model_limits:
for model_id, limit in plan.model_limits.items():
used = sub.get_model_usage(model_id)
remaining = None if limit == -1 else max(0, limit - used)
model_usage_info.append(ModelUsageInfo(
model_id=model_id,
limit=limit,
used=used,
remaining=remaining,
))
return UserSubscriptionWithPlanModel(
**sub.model_dump(),
plan=plan,
messages_remaining=messages_remaining,
days_remaining=days_remaining,
model_usage_info=model_usage_info if model_usage_info else None,
)
def get_all_subscriptions(
@@ -338,10 +427,6 @@ class UserSubscriptionsTable:
plan = SubscriptionPlans.get_plan_by_id(sub_model.plan_id)
now = int(time.time())
messages_remaining = None
if plan and plan.monthly_message_limit:
messages_remaining = max(0, plan.monthly_message_limit - sub_model.messages_used)
days_remaining = None
if sub_model.expires_at:
days_remaining = max(0, (sub_model.expires_at - now) // 86400)
@@ -349,7 +434,6 @@ class UserSubscriptionsTable:
results.append(UserSubscriptionWithPlanModel(
**sub_model.model_dump(),
plan=plan,
messages_remaining=messages_remaining,
days_remaining=days_remaining,
))
@@ -361,11 +445,11 @@ class UserSubscriptionsTable:
# create a default free plan if not exists
default_plan = SubscriptionPlans.create_plan(SubscriptionPlanForm(
id="free",
name="Free",
description="Default free plan",
name="免费版",
description="免费套餐,基础模型无限使用",
price=0,
monthly_message_limit=100,
allowed_models=[],
model_limits={},
default_model_limit=-1, # all models unlimited by default
priority=0,
is_default=True,
is_active=True,
@@ -389,6 +473,7 @@ class UserSubscriptionsTable:
expires_at=None,
current_period_start=now,
current_period_end=period_end,
model_usage={},
messages_used=0,
created_at=now,
updated_at=now,
@@ -507,6 +592,7 @@ class UserSubscriptionsTable:
db.query(UserSubscription).filter(UserSubscription.id == subscription_id).update({
"current_period_start": now,
"current_period_end": period_end,
"model_usage": {},
"messages_used": 0,
"updated_at": now,
})
@@ -515,7 +601,31 @@ class UserSubscriptionsTable:
sub = db.query(UserSubscription).filter(UserSubscription.id == subscription_id).first()
return UserSubscriptionModel.model_validate(sub)
def increment_model_usage(self, subscription_id: str, model_id: str) -> None:
"""Increment usage counter for a specific model."""
with get_db() as db:
sub = db.query(UserSubscription).filter(UserSubscription.id == subscription_id).first()
if not sub:
return
current_usage = sub.model_usage or {}
if isinstance(current_usage, str):
try:
current_usage = json.loads(current_usage)
except json.JSONDecodeError:
current_usage = {}
current_usage[model_id] = current_usage.get(model_id, 0) + 1
db.query(UserSubscription).filter(UserSubscription.id == subscription_id).update({
"model_usage": current_usage,
"messages_used": UserSubscription.messages_used + 1,
"updated_at": int(time.time()),
})
db.commit()
def increment_usage(self, subscription_id: str) -> None:
"""Deprecated: use increment_model_usage instead."""
with get_db() as db:
db.query(UserSubscription).filter(UserSubscription.id == subscription_id).update({
"messages_used": UserSubscription.messages_used + 1,
@@ -659,10 +769,11 @@ class RedemptionCodesTable:
duration_days=redemption_code.duration_days,
)
else:
# upgrade type: calculate expires_at from upgrade_days
return UserSubscriptions.upgrade_subscription(
user_id=user_id,
plan_id=redemption_code.plan_id,
expires_at=redemption_code.upgrade_expires_at,
duration_days=redemption_code.upgrade_days,
)