1515from typing import Any , ClassVar
1616
1717from open_webui .internal .db import Base , get_async_db
18- from sqlalchemy import JSON , BigInteger , Column , Text , select
18+ from sqlalchemy import JSON , BigInteger , Column , Text , delete , select
1919
2020log = logging .getLogger (__name__ )
2121
22+ API_CONFIG_KEYS = ('openai.api_configs' , 'ollama.api_configs' )
23+ DICT_CONFIG_KEY_ALIASES = {
24+ 'openai.api_configs' : ('OPENAI_API_CONFIGS' ,),
25+ 'ollama.api_configs' : ('OLLAMA_API_CONFIGS' ,),
26+ 'rag.mineru_params' : ('MINERU_PARAMS' ,),
27+ 'rag.docling_params' : ('DOCLING_PARAMS' ,),
28+ 'rag.web.search.linkup_search_params' : ('LINKUP_SEARCH_PARAMS' ,),
29+ 'image_generation.automatic1111.api_params' : ('AUTOMATIC1111_PARAMS' ,),
30+ 'image_generation.openai.params' : ('IMAGES_OPENAI_API_PARAMS' ,),
31+ 'audio.tts.openai.params' : ('AUDIO_TTS_OPENAI_PARAMS' ,),
32+ 'models.default_metadata' : ('DEFAULT_MODEL_METADATA' ,),
33+ 'models.default_params' : ('DEFAULT_MODEL_PARAMS' ,),
34+ 'user.permissions' : ('USER_PERMISSIONS' ,),
35+ }
36+ DICT_CONFIG_KEYS = tuple (DICT_CONFIG_KEY_ALIASES )
37+ API_CONFIG_FIELDS = (
38+ 'enable' ,
39+ 'key' ,
40+ 'prefix_id' ,
41+ 'tags' ,
42+ 'model_ids' ,
43+ 'connection_type' ,
44+ 'provider' ,
45+ 'auth_type' ,
46+ 'headers' ,
47+ 'azure' ,
48+ 'api_version' ,
49+ 'extra_params' ,
50+ )
51+
52+
53+ def _split_api_config_fragment (fragment : str ) -> tuple [str , list [str ]] | None :
54+ if not fragment :
55+ return None
56+
57+ first , _ , rest = fragment .partition ('.' )
58+ if first .isdigit () and rest :
59+ return first , rest .split ('.' )
60+
61+ match : tuple [int , str ] | None = None
62+ for field in API_CONFIG_FIELDS :
63+ marker = f'.{ field } '
64+ marker_index = fragment .rfind (marker )
65+ if marker_index != - 1 and (match is None or marker_index > match [0 ]):
66+ match = (marker_index , field )
67+
68+ if match :
69+ marker_index , field = match
70+ connection_key = fragment [:marker_index ]
71+ field_path = fragment [marker_index + 1 :]
72+ if connection_key :
73+ return connection_key , field_path .split ('.' )
74+
75+ return None
76+
77+
78+ def _assign_path (target : dict , path : list [str ], value : Any ) -> None :
79+ current = target
80+ for part in path [:- 1 ]:
81+ next_value = current .get (part )
82+ if not isinstance (next_value , dict ):
83+ next_value = {}
84+ current [part ] = next_value
85+ current = next_value
86+ current [path [- 1 ]] = value
87+
2288
2389# ── Model ────────────────────────────────────────────────────────────────────
2490
@@ -147,10 +213,8 @@ async def delete(key: str) -> bool:
147213 @staticmethod
148214 async def clear () -> None :
149215 """Delete all config rows."""
150- from sqlalchemy import delete as sa_delete
151-
152216 async with get_async_db () as db :
153- await db .execute (sa_delete (Config ))
217+ await db .execute (delete (Config ))
154218 await db .commit ()
155219
156220 @staticmethod
@@ -175,3 +239,71 @@ async def seed_defaults(defaults: dict) -> None:
175239 if new_count :
176240 await db .commit ()
177241 log .info ('Seeded %d new config defaults' , new_count )
242+
243+ @staticmethod
244+ async def repair_flattened_dict_configs () -> None :
245+ """Reassemble dict config values flattened by the per-key migration."""
246+ if not Config .PERSISTENT_ENABLED :
247+ return
248+
249+ async with get_async_db () as db :
250+ repaired_keys : list [str ] = []
251+ orphan_keys : list [str ] = []
252+
253+ for config_key , aliases in DICT_CONFIG_KEY_ALIASES .items ():
254+ prefixes = (config_key , * aliases )
255+ rows = []
256+ for key_prefix in prefixes :
257+ result = await db .execute (select (Config ).where (Config .key .like (f'{ key_prefix } .%' )))
258+ rows .extend (result .scalars ().all ())
259+ if not rows :
260+ continue
261+
262+ existing = await db .get (Config , config_key )
263+ repaired = existing .value if existing and isinstance (existing .value , dict ) else {}
264+
265+ repaired_any = False
266+ for row in rows :
267+ fragment = None
268+ for key_prefix in prefixes :
269+ prefix = f'{ key_prefix } .'
270+ if row .key .startswith (prefix ):
271+ fragment = row .key .removeprefix (prefix )
272+ break
273+ if fragment is None :
274+ continue
275+
276+ if config_key in API_CONFIG_KEYS :
277+ split = _split_api_config_fragment (fragment )
278+ if not split :
279+ continue
280+ object_key , field_path = split
281+ else :
282+ object_key , field_path = None , fragment .split ('.' )
283+
284+ target = repaired
285+ if object_key is not None :
286+ target = repaired .setdefault (object_key , {})
287+ if not isinstance (target , dict ):
288+ continue
289+
290+ _assign_path (target , field_path , row .value )
291+ orphan_keys .append (row .key )
292+ repaired_any = True
293+
294+ if not repaired_any :
295+ continue
296+
297+ if existing :
298+ existing .value = repaired
299+ existing .updated_at = int (time .time ())
300+ else :
301+ db .add (Config (key = config_key , value = repaired , updated_at = int (time .time ())))
302+ repaired_keys .append (config_key )
303+
304+ if orphan_keys :
305+ await db .execute (delete (Config ).where (Config .key .in_ (orphan_keys )))
306+
307+ if repaired_keys or orphan_keys :
308+ await db .commit ()
309+ log .info ('Repaired flattened dict config rows for %s' , ', ' .join (repaired_keys ))
0 commit comments