Coverage for open_webui/models/functions.py: 51%

264 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 05:07 +0000

1"""Function (filter/action/pipe) models, forms, and database operations.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import time 

7 

8# local imports 

9from open_webui.internal.db import Base, JSONField, get_async_db_context 

10from open_webui.models.users import User, UserResponse, Users, UserSettings 

11from open_webui.utils.valves import decrypt_valves, encrypt_valves 

12from pydantic import BaseModel, ConfigDict 

13from sqlalchemy import BigInteger, Boolean, Column, Index, String, Text, delete, select, update 

14from sqlalchemy.ext.asyncio import AsyncSession 

15 

16log = logging.getLogger(__name__) 

17 

18 

19class Function(Base): # database table mapping 

20 __tablename__ = 'function' 

21 

22 id = Column(String, primary_key=True, unique=True) 

23 user_id = Column(String, index=True) # creator user id 

24 name = Column(Text, nullable=False) # function identifier 

25 type = Column(Text, nullable=False) # function type (pipe, filter, etc.) 

26 content = Column(Text, nullable=True) # Python source code 

27 meta = Column(JSONField, nullable=True) # function metadata 

28 valves = Column(JSONField, nullable=True) # function configuration valves 

29 is_active = Column(Boolean, default=False) # function activation status 

30 is_global = Column(Boolean) # if True, applied to every chat automatically 

31 updated_at = Column(BigInteger) # epoch seconds 

32 created_at = Column(BigInteger) # epoch seconds 

33 

34 __table_args__ = (Index('is_global_idx', 'is_global'),) # speed up global-function lookups 

35 

36 

37class FunctionMeta(BaseModel): 

38 description: str | None = None 

39 manifest: dict | None = {} 

40 model_config = ConfigDict(extra='allow') 

41 

42 

43class FunctionModel(BaseModel): 

44 id: str 

45 user_id: str | None = None # may be null for legacy/malformed records 

46 name: str 

47 type: str 

48 content: str 

49 meta: FunctionMeta 

50 is_active: bool = False 

51 is_global: bool = False 

52 updated_at: int # timestamp in epoch 

53 created_at: int # timestamp in epoch 

54 

55 model_config = ConfigDict(from_attributes=True) # allows ORM model binding 

56 

57 

58# --- form / schema definitions --- 

59class FunctionWithValvesModel(BaseModel): 

60 id: str 

61 user_id: str | None = None # may be null for legacy/malformed records 

62 name: str 

63 type: str 

64 content: str 

65 meta: FunctionMeta 

66 valves: dict | None = None 

67 is_active: bool = False 

68 is_global: bool = False 

69 updated_at: int # timestamp in epoch 

70 created_at: int # timestamp in epoch 

71 

72 model_config = ConfigDict(from_attributes=True) 

73 

74 

75#################### 

76# Forms 

77#################### 

78 

79 

80class FunctionResponse(BaseModel): 

81 id: str 

82 user_id: str | None = None # may be null for legacy/malformed records 

83 type: str 

84 name: str 

85 meta: FunctionMeta 

86 is_active: bool 

87 is_global: bool 

88 updated_at: int # timestamp in epoch 

89 created_at: int # timestamp in epoch 

90 

91 model_config = ConfigDict(from_attributes=True) 

92 

93 

94class FunctionUserResponse(FunctionResponse): 

95 user: UserResponse | None = None 

96 

97 

98class FunctionForm(BaseModel): 

99 id: str 

100 name: str 

101 content: str 

102 meta: FunctionMeta 

103 

104 

105class FunctionValves(BaseModel): 

106 valves: dict | None = None 

107 

108 

109class FunctionsTable: 

110 async def insert_new_function( 

111 self, 

112 user_id: str, 

113 type: str, 

114 form_data: FunctionForm, 

115 db: AsyncSession | None = None, 

116 ) -> FunctionModel | None: 

117 function = FunctionModel( 

118 **{ 

119 **form_data.model_dump(), 

120 'user_id': user_id, 

121 'type': type, 

122 'updated_at': int(time.time()), 

123 'created_at': int(time.time()), 

124 } 

125 ) 

126 

127 try: 

128 async with get_async_db_context(db) as db: 

129 result = Function(**function.model_dump()) 

130 db.add(result) 

131 await db.commit() 

132 if result: 

133 return FunctionModel.model_validate(result) 

134 else: 

135 return None 

136 except Exception as e: 

137 log.exception(f'Error creating a new function: {e}') 

138 return None 

139 

140 async def sync_functions( 

141 self, 

142 user_id: str, 

143 functions: list[FunctionWithValvesModel], 

144 db: AsyncSession | None = None, 

145 ) -> list[FunctionWithValvesModel]: 

146 # Synchronize functions by updating existing ones, inserting new ones, 

147 # and removing those that are no longer present. 

148 try: 

149 async with get_async_db_context(db) as db: 

150 # Get existing functions 

151 result = await db.execute(select(Function)) 

152 existing_functions = result.scalars().all() 

153 existing_ids = {func.id for func in existing_functions} 

154 

155 # Prepare a set of new function IDs 

156 new_function_ids = {func.id for func in functions} 

157 

158 # Update or insert functions 

159 for func in functions: 

160 func_data = func.model_dump() 

161 func_data['valves'] = encrypt_valves(func_data['valves']) if func_data.get('valves') else None 

162 func_data['user_id'] = user_id 

163 func_data['updated_at'] = int(time.time()) 

164 

165 if func.id in existing_ids: 

166 await db.execute(update(Function).filter_by(id=func.id).values(**func_data)) 

167 else: 

168 new_func = Function(**func_data) 

169 db.add(new_func) 

170 

171 # Remove functions that are no longer present 

172 for func in existing_functions: 

173 if func.id not in new_function_ids: 173 ↛ anywhereline 173 didn't jump anywhere: it always raised an exception.

174 await db.delete(func) 

175 

176 await db.commit() 

177 

178 result = await db.execute(select(Function)) 

179 return [FunctionModel.model_validate(func) for func in result.scalars().all()] 

180 except Exception as e: 

181 log.exception(f'Error syncing functions for user {user_id}: {e}') 

182 return [] 

183 

184 async def get_function_by_id(self, id: str, db: AsyncSession | None = None) -> FunctionModel | None: 

185 try: 

186 async with get_async_db_context(db) as db: 

187 function = await db.get(Function, id) 

188 return FunctionModel.model_validate(function) if function else None 

189 except Exception: 

190 return None 

191 

192 async def get_functions_by_ids(self, ids: list[str], db: AsyncSession | None = None) -> list[FunctionModel]: 

193 """ 

194 Batch fetch multiple functions by their IDs in a single query. 

195 Returns functions in the same order as the input IDs (None entries filtered out). 

196 """ 

197 if not ids: 

198 return [] 

199 try: 

200 async with get_async_db_context(db) as db: 

201 result = await db.execute(select(Function).filter(Function.id.in_(ids))) 

202 functions = result.scalars().all() 

203 # Create a dict for O(1) lookup 

204 func_dict = {f.id: FunctionModel.model_validate(f) for f in functions} 

205 # Return in original order, filtering out any not found 

206 return [func_dict[id] for id in ids if id in func_dict] 

207 except Exception: 

208 return [] 

209 

210 async def get_functions( 

211 self, active_only=False, include_valves=False, db: AsyncSession | None = None 

212 ) -> list[FunctionModel | FunctionWithValvesModel]: 

213 async with get_async_db_context(db) as db: 

214 if active_only: 

215 result = await db.execute(select(Function).filter_by(is_active=True)) 

216 else: 

217 result = await db.execute(select(Function)) 

218 

219 functions = result.scalars().all() 

220 

221 if include_valves: 

222 return [ 

223 FunctionWithValvesModel.model_validate( 

224 { 

225 **FunctionModel.model_validate(function).model_dump(), 

226 'valves': decrypt_valves(function.valves), 

227 } 

228 ) 

229 for function in functions 

230 ] 

231 else: 

232 return [FunctionModel.model_validate(function) for function in functions] 

233 

234 async def get_function_list(self, db: AsyncSession | None = None) -> list[FunctionUserResponse]: 

235 async with get_async_db_context(db) as db: 

236 result = await db.execute(select(Function).order_by(Function.updated_at.desc())) 

237 functions = result.scalars().all() 

238 user_ids = list(set(func.user_id for func in functions)) 

239 

240 users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] 

241 users_dict = {user.id: user for user in users} 

242 

243 return [ 

244 FunctionUserResponse.model_validate( 

245 { 

246 **FunctionResponse.model_validate(func).model_dump(), 

247 'user': ( 

248 UserResponse( 

249 id=users_dict[func.user_id].id, 

250 name=users_dict[func.user_id].name, 

251 role=users_dict[func.user_id].role, 

252 email=users_dict[func.user_id].email, 

253 ).model_dump() 

254 if func.user_id in users_dict 

255 else None 

256 ), 

257 } 

258 ) 

259 for func in functions 

260 ] 

261 

262 async def get_functions_by_type( 

263 self, type: str, active_only=False, db: AsyncSession | None = None 

264 ) -> list[FunctionModel]: 

265 async with get_async_db_context(db) as db: 

266 if active_only: 266 ↛ 269line 266 didn't jump to line 269 because the condition on line 266 was always true

267 result = await db.execute(select(Function).filter_by(type=type, is_active=True)) 

268 else: 

269 result = await db.execute(select(Function).filter_by(type=type)) 

270 return [FunctionModel.model_validate(function) for function in result.scalars().all()] 

271 

272 async def get_global_filter_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]: 

273 async with get_async_db_context(db) as db: 

274 result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True)) 

275 return [FunctionModel.model_validate(function) for function in result.scalars().all()] 

276 

277 async def get_active_function_ids_by_type( 

278 self, type: str, db: AsyncSession | None = None 

279 ) -> list[tuple[str, bool]]: 

280 """Return (id, is_global) for active functions without fetching plugin source.""" 

281 async with get_async_db_context(db) as db: 

282 result = await db.execute(select(Function.id, Function.is_global).filter_by(type=type, is_active=True)) 

283 return [(id, bool(is_global)) for id, is_global in result.all()] 

284 

285 async def get_active_filter_ids(self, db: AsyncSession | None = None) -> list[tuple[str, bool]]: 

286 """Return (id, is_global) for active filters without fetching plugin source.""" 

287 return await self.get_active_function_ids_by_type('filter', db=db) 

288 

289 async def get_global_action_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]: 

290 async with get_async_db_context(db) as db: 

291 result = await db.execute(select(Function).filter_by(type='action', is_active=True, is_global=True)) 

292 return [FunctionModel.model_validate(function) for function in result.scalars().all()] 

293 

294 async def get_function_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None: 

295 async with get_async_db_context(db) as db: 

296 try: 

297 result = await db.execute(select(Function.valves).filter_by(id=id)) 

298 return decrypt_valves(result.scalar_one_or_none()) 

299 except Exception as e: 

300 log.exception(f'Error getting function valves by id {id}: {e}') 

301 return None 

302 

303 async def get_function_valves_by_ids(self, ids: list[str], db: AsyncSession | None = None) -> dict[str, dict]: 

304 """ 

305 Batch fetch valves for multiple functions in a single query. 

306 Returns a dict mapping function_id -> valves dict. 

307 Functions without valves are mapped to {}. 

308 """ 

309 if not ids: 

310 return {} 

311 try: 

312 async with get_async_db_context(db) as db: 

313 result = await db.execute(select(Function.id, Function.valves).filter(Function.id.in_(ids))) 

314 return {id: decrypt_valves(valves) for id, valves in result.all()} 

315 except Exception as e: 

316 log.exception(f'Error batch-fetching function valves: {e}') 

317 return {} 

318 

319 async def update_function_valves_by_id( 

320 self, id: str, valves: dict, db: AsyncSession | None = None 

321 ) -> FunctionValves | None: 

322 async with get_async_db_context(db) as db: 

323 try: 

324 function = await db.get(Function, id) 

325 function.valves = encrypt_valves(valves) 

326 function.updated_at = int(time.time()) 

327 await db.commit() 

328 return FunctionModel.model_validate(function) 

329 except Exception: 

330 return None 

331 

332 async def update_function_metadata_by_id( 

333 self, id: str, metadata: dict, db: AsyncSession | None = None 

334 ) -> FunctionModel | None: 

335 async with get_async_db_context(db) as db: 

336 try: 

337 function = await db.get(Function, id) 

338 

339 if function: 

340 if function.meta: 

341 function.meta = {**function.meta, **metadata} 

342 else: 

343 function.meta = metadata 

344 

345 function.updated_at = int(time.time()) 

346 await db.commit() 

347 return FunctionModel.model_validate(function) 

348 else: 

349 return None 

350 except Exception as e: 

351 log.exception(f'Error updating function metadata by id {id}: {e}') 

352 return None 

353 

354 async def get_user_valves_by_id_and_user_id( 

355 self, id: str, user_id: str, db: AsyncSession | None = None 

356 ) -> dict | None: 

357 try: 

358 async with get_async_db_context(db) as db: 

359 result = await db.execute(select(User.settings).filter_by(id=user_id)) 

360 settings = result.scalar_one_or_none() 

361 

362 user_settings = UserSettings(**settings).model_dump() if settings else {} 

363 

364 # Check if user has "functions" and "valves" settings 

365 if 'functions' not in user_settings: 

366 user_settings['functions'] = {} 

367 if 'valves' not in user_settings['functions']: 

368 user_settings['functions']['valves'] = {} 

369 

370 return decrypt_valves(user_settings['functions']['valves'].get(id)) 

371 except Exception: 

372 log.exception(f'Error getting user values by id {id} and user id {user_id}') 

373 return None 

374 

375 async def update_user_valves_by_id_and_user_id( 

376 self, id: str, user_id: str, valves: dict, db: AsyncSession | None = None 

377 ) -> dict | None: 

378 try: 

379 user = await Users.get_user_by_id(user_id, db=db) 

380 user_settings = user.settings.model_dump() if user.settings else {} 

381 

382 # Check if user has "functions" and "valves" settings 

383 if 'functions' not in user_settings: 383 ↛ 385line 383 didn't jump to line 385 because the condition on line 383 was always true

384 user_settings['functions'] = {} 

385 if 'valves' not in user_settings['functions']: 385 ↛ 388line 385 didn't jump to line 388 because the condition on line 385 was always true

386 user_settings['functions']['valves'] = {} 

387 

388 user_settings['functions']['valves'][id] = encrypt_valves(valves) 

389 

390 # Update the user settings in the database 

391 await Users.update_user_by_id(user_id, {'settings': user_settings}, db=db) 

392 

393 return valves 

394 except Exception as e: 

395 log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}') 

396 return None 

397 

398 async def update_function_by_id( 

399 self, id: str, updated: dict, db: AsyncSession | None = None 

400 ) -> FunctionModel | None: 

401 async with get_async_db_context(db) as db: 

402 try: 

403 await db.execute( 

404 update(Function) 

405 .filter_by(id=id) 

406 .values( 

407 **updated, 

408 updated_at=int(time.time()), 

409 ) 

410 ) 

411 await db.commit() 

412 function = await db.get(Function, id) 

413 return FunctionModel.model_validate(function) if function else None 

414 except Exception: 

415 return None 

416 

417 async def deactivate_all_functions(self, db: AsyncSession | None = None) -> bool | None: 

418 async with get_async_db_context(db) as db: 

419 try: 

420 await db.execute( 

421 update(Function).values( 

422 is_active=False, 

423 updated_at=int(time.time()), 

424 ) 

425 ) 

426 await db.commit() 

427 return True 

428 except Exception: 

429 return None 

430 

431 async def delete_function_by_id(self, id: str, db: AsyncSession | None = None) -> bool: 

432 async with get_async_db_context(db) as db: 

433 try: 

434 await db.execute(delete(Function).filter_by(id=id)) 

435 await db.commit() 

436 

437 return True 

438 except Exception: 

439 return False 

440 

441 

442Functions = FunctionsTable() # singleton functions engine