Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/common_utils/periodic_reload_schedule.py: 48%

98 statements  

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

1""" 

2Persistence for the admin-configured periodic model cost map reload schedule stored in 

3``LiteLLM_Config``. 

4 

5Field ownership is split by writer so concurrent writers never overwrite each other: 

6the schedule endpoints own the ``param_value`` JSON (``interval_hours``), while the 

7reload job and the manual reload endpoints own the dedicated ``last_run_at`` / 

8``reload_revision`` columns. ``last_run_at`` lives in the row rather than process memory 

9so the Admin UI still reports the last execution after a restart and across pods. 

10``reload_revision`` is a monotonic counter a manual reload increments; each pod records 

11the revision it last applied and reloads whenever the row's differs, so a request reaches 

12every pod exactly once without any pod clearing it and without comparing clocks. A booting 

13pod starts at revision 0 rather than adopting the published one, because it cannot know 

14whether that request predates the prices it fetched at import. Interval reloads stay 

15per-pod, driven by when that pod's own copy of the data was loaded. 

16""" 

17 

18from collections.abc import Mapping 

19from dataclasses import dataclass 

20from datetime import datetime, timedelta, timezone 

21from typing import ( 

22 TYPE_CHECKING, 

23 Protocol, 

24 TypedDict, 

25 cast, # noqa: TID251 # prisma table access is untyped (PrismaWrapper.__getattr__) 

26) 

27 

28from pydantic import BaseModel, ConfigDict, ValidationError 

29 

30from litellm._logging import verbose_proxy_logger 

31from litellm.litellm_core_utils.safe_json_dumps import safe_dumps 

32from litellm.proxy.utils import PrismaClient, evict_config_param 

33from litellm.repositories.config_repository import ConfigRepository 

34 

35if TYPE_CHECKING: 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true

36 from prisma.models import LiteLLM_Config 

37 

38MODEL_COST_MAP_RELOAD_PARAM_NAME = "model_cost_map_reload_config" 

39 

40 

41class _RevisionIncrement(TypedDict): 

42 increment: int 

43 

44 

45class _ConfigRowWrite(TypedDict, total=False): 

46 param_name: str 

47 param_value: str 

48 last_run_at: datetime 

49 reload_revision: int | _RevisionIncrement 

50 

51 

52class _ConfigUpsertData(TypedDict): 

53 create: _ConfigRowWrite 

54 update: _ConfigRowWrite 

55 

56 

57class _ConfigTable(Protocol): 

58 async def find_unique(self, where: Mapping[str, str]) -> "LiteLLM_Config | None": ... 58 ↛ exitline 58 didn't return from function 'find_unique' because

59 

60 async def upsert(self, where: Mapping[str, str], data: _ConfigUpsertData) -> "LiteLLM_Config": ... 60 ↛ exitline 60 didn't return from function 'upsert' because

61 

62 async def update_many(self, data: _ConfigRowWrite, where: Mapping[str, str]) -> int: ... 62 ↛ exitline 62 didn't return from function 'update_many' because

63 

64 

65def _config_table(prisma_client: PrismaClient) -> _ConfigTable: 

66 return cast(_ConfigTable, ConfigRepository(prisma_client).table) # cast-ok: prisma table is untyped (Any) 

67 

68 

69@dataclass(frozen=True, slots=True) 

70class ReloadSchedule: 

71 interval_hours: int | None = None 

72 reload_revision: int = 0 

73 last_run_at: datetime | None = None 

74 

75 

76class ReloadScheduleStatus(TypedDict): 

77 scheduled: bool 

78 interval_hours: int | None 

79 last_run: str | None 

80 next_run: str | None 

81 

82 

83class _IntervalConfig(BaseModel): 

84 model_config = ConfigDict(strict=True) 

85 

86 interval_hours: int | None = None 

87 

88 

89def utc_now() -> datetime: 

90 return datetime.now(timezone.utc) 

91 

92 

93def _parse_interval_hours(param_value: object) -> int | None: 

94 """``param_value`` is written as serialized JSON, and a raw row read can hand it back 

95 either decoded or still as a string depending on the driver, so accept both rather than 

96 reading a string as no schedule at all. Mirrors ``ConfigRepository.get_param``""" 

97 try: 

98 if isinstance(param_value, str): 

99 return _IntervalConfig.model_validate_json(param_value).interval_hours 

100 return _IntervalConfig.model_validate(param_value).interval_hours 

101 except ValidationError: 

102 return None 

103 

104 

105def _as_utc(value: datetime | None) -> datetime | None: 

106 if value is None: 

107 return None 

108 return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) 

109 

110 

111def parse_reload_schedule(row: "LiteLLM_Config") -> ReloadSchedule: 

112 return ReloadSchedule( 

113 interval_hours=_parse_interval_hours(row.param_value), 

114 reload_revision=int(row.reload_revision or 0), 

115 last_run_at=_as_utc(row.last_run_at), 

116 ) 

117 

118 

119def next_run_at(schedule: ReloadSchedule) -> datetime | None: 

120 if schedule.interval_hours is None or schedule.last_run_at is None: 

121 return None 

122 return schedule.last_run_at + timedelta(hours=schedule.interval_hours) 

123 

124 

125def reload_schedule_status(schedule: ReloadSchedule | None) -> ReloadScheduleStatus: 

126 if schedule is None: 

127 return {"scheduled": False, "interval_hours": None, "last_run": None, "next_run": None} 

128 next_run = next_run_at(schedule) 

129 return { 

130 "scheduled": schedule.interval_hours is not None, 

131 "interval_hours": schedule.interval_hours, 

132 "last_run": schedule.last_run_at.isoformat() if schedule.last_run_at is not None else None, 

133 "next_run": next_run.isoformat() if next_run is not None else None, 

134 } 

135 

136 

137def pod_reload_is_due( 

138 *, 

139 schedule: ReloadSchedule, 

140 pod_applied_revision: int, 

141 pod_data_loaded_at: datetime, 

142 current_time: datetime, 

143 description: str, 

144) -> bool: 

145 """ 

146 Whether this pod should reload now. A revision it has not applied means a manual reload 

147 it has not served. A pod starts at revision 0, so it serves any request published before 

148 it booted; that costs one redundant fetch per boot and is what keeps a request from being 

149 marked applied against data fetched before it. Interval reloads compare against this pod's 

150 own data, and a schedule that has never run anywhere fires immediately rather than one 

151 interval later 

152 """ 

153 if schedule.reload_revision != pod_applied_revision: 

154 verbose_proxy_logger.info("%s reload triggered by manual reload request", description) 

155 return True 

156 if schedule.interval_hours is None: 

157 return False 

158 if schedule.last_run_at is None: 

159 verbose_proxy_logger.info("%s reload triggered - schedule has never run", description) 

160 return True 

161 hours_since_data_loaded = (current_time - pod_data_loaded_at).total_seconds() / 3600 

162 if hours_since_data_loaded < schedule.interval_hours: 

163 return False 

164 verbose_proxy_logger.info( 

165 "%s reload triggered by interval. Hours since data loaded: %.2f, Interval: %s", 

166 description, 

167 hours_since_data_loaded, 

168 schedule.interval_hours, 

169 ) 

170 return True 

171 

172 

173async def read_reload_schedule(prisma_client: PrismaClient, param_name: str) -> ReloadSchedule | None: 

174 row = await _config_table(prisma_client).find_unique(where={"param_name": param_name}) 

175 if row is None: 175 ↛ 177line 175 didn't jump to line 177 because the condition on line 175 was always true

176 return None 

177 return parse_reload_schedule(row) 

178 

179 

180async def write_reload_interval(prisma_client: PrismaClient, param_name: str, interval_hours: int) -> None: 

181 """Admin-owned write: replaces ``param_value`` without touching the job-owned columns""" 

182 param_value = safe_dumps({"interval_hours": interval_hours}) 

183 await _config_table(prisma_client).upsert( 

184 where={"param_name": param_name}, 

185 data={ 

186 "create": {"param_name": param_name, "param_value": param_value}, 

187 "update": {"param_value": param_value}, 

188 }, 

189 ) 

190 await evict_config_param(param_name) 

191 

192 

193async def clear_reload_interval(prisma_client: PrismaClient, param_name: str) -> None: 

194 """Admin-owned write: drops the schedule but keeps the row, because the revision counter 

195 identifies a request rather than ordering one and so can never reuse a number. Deleting 

196 the row restarts it, and a reissued revision matches what pods already applied, so their 

197 next manual reload is silently skipped. The interval is nulled inside the JSON rather 

198 than by nulling the column, which prisma rejects for a ``Json?`` field""" 

199 await _config_table(prisma_client).update_many( 

200 data={"param_value": safe_dumps({"interval_hours": None})}, 

201 where={"param_name": param_name}, 

202 ) 

203 await evict_config_param(param_name) 

204 

205 

206async def record_reload_run(prisma_client: PrismaClient, param_name: str, ran_at: datetime) -> None: 

207 """Job-owned write after this pod reloaded: stamps the shared last run only if the row 

208 still exists, so a schedule deleted mid-poll is not resurrected""" 

209 await _config_table(prisma_client).update_many( 

210 data={"last_run_at": ran_at}, 

211 where={"param_name": param_name}, 

212 ) 

213 await evict_config_param(param_name) 

214 

215 

216async def record_manual_reload(prisma_client: PrismaClient, param_name: str, ran_at: datetime) -> int: 

217 """ 

218 After a manual in-pod reload: stamp the shared last run and bump the revision every other 

219 pod compares against. The increment is atomic, so concurrent requests each publish a 

220 distinct revision instead of overwriting one another. Returns the published revision so 

221 the serving pod can adopt it rather than reloading again on its next poll 

222 """ 

223 row = await _config_table(prisma_client).upsert( 

224 where={"param_name": param_name}, 

225 data={ 

226 "create": {"param_name": param_name, "last_run_at": ran_at, "reload_revision": 1}, 

227 "update": {"last_run_at": ran_at, "reload_revision": {"increment": 1}}, 

228 }, 

229 ) 

230 await evict_config_param(param_name) 

231 return int(row.reload_revision)