Repository navigation
fix(proxy): backport #43962 to rc/1.104.0 #44054
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -489,9 +489,13 @@ def generate_feedback_box(): | |
| ) | ||
| from litellm.proxy.config_resolvers.changed_section_keys import changed_section_keys | ||
| from litellm.proxy.config_resolvers.settings_rules import ( | ||
| ABSENT, | ||
| DbRow, | ||
| Section, | ||
| SettingValue, | ||
| coerce_bool, | ||
| is_absent, | ||
| is_resource_list, | ||
| ) | ||
| from litellm.proxy.config_resolvers.settings_rules import ( | ||
| JsonValue as SettingsJsonValue, | ||
|
|
@@ -5047,6 +5051,8 @@ def update_baseline(self, config: Mapping[str, object]) -> None: | |
|
|
||
| _EMPTY_SETTINGS_MAPPING: Final[Mapping[str, SettingsJsonValue]] = MappingProxyType({}) | ||
| _SETTINGS_MAPPING: Final = TypeAdapter(dict[str, SettingsJsonValue]) | ||
| _SETTINGS_LIST: Final = TypeAdapter(list[SettingsJsonValue]) | ||
| _ENDPOINT_DICTS: Final = TypeAdapter(list[dict[str, object]]) | ||
|
|
||
|
|
||
| def _as_settings_mapping(value: object) -> Mapping[str, SettingsJsonValue]: | ||
|
|
@@ -5061,6 +5067,40 @@ def _get_field_default(field_info: FieldInfo) -> JsonValue: | |
| return cast(JsonValue, field_info.default) # cast-ok: Pydantic field defaults are JSON values at runtime | ||
|
|
||
|
|
||
| def _pass_through_endpoints_beside_db(db_endpoints: object, config_endpoints: object) -> list[SettingsJsonValue]: | ||
| stored: Final = db_endpoints if isinstance(db_endpoints, list) else () | ||
| declared: Final = config_endpoints if isinstance(config_endpoints, list) else () | ||
| db_paths: Final = frozenset(endpoint.get("path") for endpoint in stored if isinstance(endpoint, dict)) | ||
| beside_db: Final = ( | ||
| endpoint for endpoint in declared if not isinstance(endpoint, dict) or endpoint.get("path") not in db_paths | ||
| ) | ||
| return _SETTINGS_LIST.validate_python((*stored, *beside_db)) | ||
|
|
||
|
|
||
| def _with_config_file_pass_through_endpoints( | ||
| section_config: object, resolved: Mapping[str, SettingsJsonValue], db_endpoints: SettingValue | ||
| ) -> Mapping[str, object]: | ||
| config_endpoints: Final = ( | ||
| section_config.get("pass_through_endpoints") if isinstance(section_config, Mapping) else None | ||
| ) | ||
| if config_endpoints is None and not isinstance(db_endpoints, list) and "pass_through_endpoints" not in resolved: | ||
| return resolved | ||
| return MappingProxyType( | ||
| { | ||
| **resolved, | ||
| "pass_through_endpoints": _pass_through_endpoints_beside_db(db_endpoints, config_endpoints), | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| def _reload_settings_store(section: Section, store: SettingsStore, section_config: object) -> None: | ||
| serving_pass_throughs: Final = store.get("pass_through_endpoints") | ||
| store.load_yaml(_as_settings_mapping(section_config)) | ||
| store.apply_db_row(section, _EMPTY_SETTINGS_MAPPING) | ||
| if is_resource_list(section, "pass_through_endpoints") and serving_pass_throughs is not None: | ||
| store["pass_through_endpoints"] = serving_pass_throughs | ||
|
|
||
|
|
||
| def _bind_general_settings_store(settings: SettingsStore) -> None: | ||
| global general_settings | ||
| general_settings = settings # pyright: ignore[reportAssignmentType] # legacy global accepts mappings | ||
|
|
@@ -5151,22 +5191,18 @@ def __init__(self) -> None: | |
| ) | ||
|
|
||
| def _load_yaml_settings_stores(self, config: Mapping[str, object]) -> None: | ||
| global config_passthrough_endpoints | ||
| for section, store in self._settings_stores.items(): | ||
| store.load_yaml(_as_settings_mapping(config.get(section))) | ||
| store.apply_db_row(section, _EMPTY_SETTINGS_MAPPING) | ||
| yaml_endpoints: Final = self.settings.config_value("pass_through_endpoints") | ||
| config_passthrough_endpoints = ( | ||
| [dict(endpoint) for endpoint in yaml_endpoints if isinstance(endpoint, dict)] | ||
| if isinstance(yaml_endpoints, list) | ||
| else None | ||
| ) | ||
| _reload_settings_store(section, store, config.get(section)) | ||
|
Comment on lines
5193
to
+5195
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
How this was verified: YAML reload does not update the global endpoint list, and route initialization uses that list to decide whether to attach authentication. Knowledge Base Used:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium: Reloads retain stale pass-through authentication
Refresh the YAML-only endpoint snapshot on reload before publishing and registering the merged list, retaining the previous serving state separately during database reads. |
||
|
|
||
| def _config_with_resolved_settings(self, config: Mapping[str, object]) -> dict[str, object]: | ||
| return { # mutable-ok: get_config preserves the mutable mapping contract used by existing loaders | ||
| **config, | ||
| **{ | ||
| section: dict(store.resolved()) | ||
| section: dict( | ||
| _with_config_file_pass_through_endpoints( | ||
| config.get(section), store.resolved(), store.db_value("pass_through_endpoints") | ||
| ) | ||
| ) | ||
| for section, store in self._settings_stores.items() | ||
| if isinstance(config.get(section), Mapping) or len(store) > 0 | ||
| }, | ||
|
|
@@ -6525,6 +6561,7 @@ async def load_config(self, router: litellm.Router | None, config_file_path: str | |
|
|
||
| ## pass through endpoints | ||
| if general_settings.get("pass_through_endpoints", None) is not None: | ||
| config_passthrough_endpoints = general_settings["pass_through_endpoints"] | ||
| await initialize_pass_through_endpoints( | ||
| pass_through_endpoints=general_settings["pass_through_endpoints"], | ||
| config_file_path=config_file_path, | ||
|
|
@@ -7548,14 +7585,12 @@ async def _update_general_settings(self, db_general_settings: Mapping[str, Setti | |
| self.settings.load_yaml(_as_settings_mapping(general_settings)) | ||
| cache_size_was_db: Final = self.settings.source("user_api_key_cache_max_size") == "db" | ||
| previous_retention_values: Final = self._resolved_retention_values() | ||
| previous_pass_through_endpoints: Final = self.settings.get("pass_through_endpoints") | ||
| self.settings.apply_db_row("general_settings", db_general_settings) | ||
| _bind_general_settings_store(self.settings) | ||
| await self._apply_general_settings_side_effects( | ||
| db_general_settings, | ||
| cache_size_was_db, | ||
| previous_retention_values, | ||
| previous_pass_through_endpoints, | ||
| ) | ||
|
|
||
| def _resolved_retention_values(self) -> tuple[SettingsJsonValue | None, ...]: | ||
|
|
@@ -7573,11 +7608,10 @@ async def _apply_general_settings_side_effects( | |
| db_values: Mapping[str, SettingsJsonValue], | ||
| cache_size_was_db: bool, | ||
| previous_retention_values: tuple[SettingsJsonValue | None, ...], | ||
| previous_pass_through_endpoints: SettingsJsonValue | None, | ||
| ) -> None: | ||
| effects: Final = ( | ||
| self._apply_alerting_settings, | ||
| partial(self._apply_pass_through_settings, previous_endpoints=previous_pass_through_endpoints), | ||
| self._apply_pass_through_settings, | ||
| self._apply_boolean_settings, | ||
| partial(self._apply_cache_size_setting, cache_size_was_db=cache_size_was_db), | ||
| self._apply_store_model_in_db_setting, | ||
|
|
@@ -7610,19 +7644,23 @@ async def _apply_alerting_settings(self, db_values: Mapping[str, SettingsJsonVal | |
| if "plugins" in db_values and self.settings.source("plugins") == "db": | ||
| register_plugins_from_config(self.settings) | ||
|
|
||
| async def _apply_pass_through_settings( | ||
| self, | ||
| db_values: Mapping[str, SettingsJsonValue], | ||
| previous_endpoints: SettingsJsonValue | None, | ||
| ) -> None: | ||
| del db_values | ||
| resolved_endpoints: Final = self.settings.get("pass_through_endpoints") | ||
| if resolved_endpoints == previous_endpoints: | ||
| async def _apply_pass_through_settings(self, db_values: Mapping[str, SettingsJsonValue]) -> None: | ||
| db_endpoints: Final = db_values.get("pass_through_endpoints") | ||
| if isinstance(db_endpoints, list): | ||
| await self._serve_pass_through_endpoints(db_endpoints) | ||
| return | ||
| await initialize_pass_through_endpoints( | ||
| pass_through_endpoints=resolved_endpoints if isinstance(resolved_endpoints, list) else [] | ||
| if "pass_through_endpoints" not in self.settings: | ||
| self._publish_pass_through_endpoints(()) | ||
|
Comment on lines
+7652
to
+7653
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
How this was verified: The absent-field branch only updates settings; stale route entries are removed during initialization, which the supported-object setting can skip. Knowledge Base Used: |
||
|
|
||
| def _publish_pass_through_endpoints(self, db_endpoints: Sequence[SettingsJsonValue]) -> None: | ||
| self.settings["pass_through_endpoints"] = _pass_through_endpoints_beside_db( | ||
| list(db_endpoints), config_passthrough_endpoints | ||
| ) | ||
|
|
||
| async def _serve_pass_through_endpoints(self, db_endpoints: Sequence[SettingsJsonValue]) -> None: | ||
| self._publish_pass_through_endpoints(db_endpoints) | ||
| await initialize_pass_through_endpoints(pass_through_endpoints=_ENDPOINT_DICTS.validate_python(db_endpoints)) | ||
|
|
||
| async def _apply_boolean_settings(self, db_values: Mapping[str, SettingsJsonValue]) -> None: | ||
| for key in ( | ||
| "store_prompts_in_spend_logs", | ||
|
|
@@ -18030,6 +18068,9 @@ async def update_config_general_settings( | |
| ) | ||
| await invalidate_config_param("general_settings") | ||
| proxy_config.settings.apply_db_row("general_settings", general_settings) | ||
| if is_resource_list("general_settings", data.field_name): | ||
| stored_endpoints: Final = general_settings.get("pass_through_endpoints") | ||
| await proxy_config._serve_pass_through_endpoints(stored_endpoints if isinstance(stored_endpoints, list) else ()) | ||
| asyncio.create_task( | ||
| create_config_audit_log( | ||
| "general_settings", "updated", before_general_settings, general_settings, user_api_key_dict | ||
|
|
@@ -18181,6 +18222,21 @@ def _apply_webhook_role_gate(webhook_map, is_full_admin: bool): | |
| return {alert_type: "REDACTED" for alert_type in webhook_map} | ||
|
|
||
|
|
||
| async def _declared_general_setting( | ||
| settings: SettingsStore, field_name: str, prisma_client: PrismaClient | ||
| ) -> SettingValue: | ||
| if is_resource_list("general_settings", field_name): | ||
| writer_table: Final = cast( # cast-ok: prisma's LiteLLM_Config actions object on the writer connection | ||
| "TableActions[_ConfigParamRow]", prisma_client.writer_db.litellm_config | ||
| ) | ||
| row: Final = await writer_table.find_first(where={"param_name": "general_settings"}) | ||
| stored: Final = row.param_value if row is not None and isinstance(row.param_value, Mapping) else {} | ||
| return stored.get(field_name, ABSENT) if stored.get(field_name) is not None else ABSENT | ||
| if field_name not in settings: | ||
| return ABSENT | ||
| return settings.config_value(field_name) if settings.owned_by_config(field_name) else settings[field_name] | ||
|
|
||
|
|
||
| @router.get( | ||
| "/config/field/info", | ||
| tags=["config.yaml"], | ||
|
|
@@ -18219,15 +18275,12 @@ async def get_config_general_settings( | |
| ) | ||
|
|
||
| settings: Final = proxy_config.settings | ||
| if field_name not in settings: | ||
| declared: Final = await _declared_general_setting(settings, field_name, prisma_client) | ||
| if is_absent(declared): | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail={"error": f"Field name={field_name} is not set"}, | ||
| ) | ||
|
|
||
| declared: Final = ( | ||
| settings.config_value(field_name) if settings.owned_by_config(field_name) else settings[field_name] | ||
| ) | ||
| field_value = _redact_general_setting_value( | ||
| field_name, | ||
| declared, | ||
|
|
@@ -18638,6 +18691,9 @@ async def delete_config_general_settings( | |
| ) | ||
| await invalidate_config_param("general_settings") | ||
| proxy_config.settings.apply_db_row("general_settings", general_settings) | ||
| if is_resource_list("general_settings", data.field_name): | ||
| stored_endpoints: Final = general_settings.get("pass_through_endpoints") | ||
| await proxy_config._serve_pass_through_endpoints(stored_endpoints if isinstance(stored_endpoints, list) else ()) | ||
| asyncio.create_task( | ||
| create_config_audit_log( | ||
| "general_settings", "deleted", before_general_settings, general_settings, user_api_key_dict | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
POSTwithauth: falseand a YAML endpoint servesGETwithauth: trueon the same path, both methods can have routes, but this merge removes the YAML entry by path alone. Authentication then sees only the DB entry and can let a GET request through without a key.How this was verified: Route registration distinguishes disjoint methods, but the merged authentication settings discard the YAML entry based only on its path.
Knowledge Base Used: Proxy authentication and authorization