Skip to content

fix(proxy): backport #43962 to rc/1.104.0 - #44054

Merged
yuneng-berri merged 1 commit into
rc/1.104.0from
litellm_backport_lit9020_rc_1_104_0
Oct 1, 2026
Merged

yuneng-berri merged 1 commit into
rc/1.104.0from
litellm_backport_lit9020_rc_1_104_0

Conversation

@yuneng-berri

@yuneng-berri yuneng-berri commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Backport of #43962 to rc/1.104.0, one cherry-pick -x of 2eb2bf1. Same change as the stable/1.103.x backport in #43984

Problem this solves:

  • Config pass-throughs with forward_headers: true stopped forwarding Authorization
  • Only with STORE_MODEL_IN_DB=true, starting in v1.103.0, and still present on rc/1.104.0
  • UI create, update and delete of pass-throughs was rejected once the config declared any
  • os.environ/ pass-through targets were sent upstream unresolved and returned 500

How it solves it:

  • Puts pass-throughs back on their pre-refactor(proxy): make the config file win over the database #41779 handling, a targeted revert of that one key
  • The config file no longer owns general_settings.pass_through_endpoints, so the DB reader sees DB rows only
  • Each DB sync and every config reload again serve DB entries next to config entries on other paths
  • A UI pass-through write re-applies that merge right away, so config routes keep serving until the next sync

User Flow

Before: a team whose backend validates the caller's JWT gets "token not found" for every request through a config pass-through

  1. The admin declares a pass-through with forward_headers: true and auth: false in the config, with STORE_MODEL_IN_DB=true
  2. A client sends POST https://litellm-domain/copilot with Authorization: Bearer <jwt>
  3. The backend receives the request with no Authorization header and rejects it

After: the same request reaches the backend with the caller's JWT, right after boot and after every DB sync

Affected release

Regression in v1.103.0-rc.1, present on rc/1.104.0

Linear ticket

Resolves LIT-9020

Backport notes

Two conflicts and one adaptation, same as #43984:

  • _update_general_settings and its side-effects helper: kept rc's previous_retention_values and dropped the previous_pass_through_endpoints argument, which fix(proxy): restore pre-config-wins handling of pass-through endpoints #43962 removes. The matching test expectation in test_proxy_config.py got the same resolution
  • _declared_general_setting reads the stored row through prisma_client.writer_db.litellm_config, because rc/1.104.0's ConfigRepository has no use_writer argument. Without it the field read raises inside a swallowed try and DB pass-throughs never register

The rest of the litellm/ diff matches main line for line

Pre-Submission checklist

  • I have added meaningful tests
  • The handful of test files covering my change pass locally
  • My PR passes all required CI/CD checks
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Live proxy on Postgres with STORE_MODEL_IN_DB=True, PT_ENV_TARGET=http://127.0.0.1:9081/api/envtarget, and a local echo server on :9081 that returns the path and Authorization header it received. The same scenario script ran against both commits, each on a fresh database, with about 25 seconds between steps so DB sync cycles run in between. Config:

model_list: []
general_settings:
  master_key: <master key>
  pass_through_endpoints:
    - path: "/copilot"
      target: "http://127.0.0.1:9081/api/copilot"
      forward_headers: true
      include_subpath: true
      auth: false
    - path: "/envtarget"
      target: "os.environ/PT_ENV_TARGET"
      auth: false

Before (29c35ba, rc/1.104.0)

After boot and first DB sync

curl -s -X POST "http://localhost:4097/copilot" -H "content-type: application/json" -H "Authorization: Bearer caller-jwt" -d '{"q":1}'
{"path": "/api/copilot", "authorization": null} [HTTP 200]

After further DB syncs

curl -s -X POST "http://localhost:4097/copilot" -H "content-type: application/json" -H "Authorization: Bearer caller-jwt" -d '{"q":1}'
{"path": "/api/copilot", "authorization": null} [HTTP 200]
curl -s -X POST "http://localhost:4097/envtarget" -H "content-type: application/json" -d '{}'
{"error":{"message":"/os.environ/PT_ENV_TARGET","type":"internal_server_error","param":null,"code":"500"}} [HTTP 500]

UI create

curl -s -X POST "http://localhost:4097/config/pass_through_endpoint" -H "content-type: application/json" -H "Authorization: Bearer $MASTER_KEY" -d '{"path":"/snowflake","target":"http://127.0.0.1:9081/api/snowflake","auth":false}'
{"detail":{"error":"general_settings key 'pass_through_endpoints' is set in the config file and cannot be changed here.","keys":["pass_through_endpoints"],"section":"general_settings","stored_database_values_ignored":[],"resolution":"edit config.yaml to change it, or remove it from the file to let the database own it"}} [HTTP 400]
curl -s -X POST "http://localhost:4097/snowflake" -H "content-type: application/json" -d '{}'
{"detail":"Not Found"} [HTTP 404]

After a DB sync

curl -s -X POST "http://localhost:4097/copilot" -H "content-type: application/json" -H "Authorization: Bearer caller-jwt" -d '{"q":1}'
{"path": "/api/copilot", "authorization": null} [HTTP 200]

List pass-throughs (summarized as path, is_from_config, auth)

curl -s "http://localhost:4097/config/pass_through_endpoint" -H "Authorization: Bearer $MASTER_KEY"
/copilot is_from_config=False auth=False
/envtarget is_from_config=False auth=False

After (f0690d7)

After boot and first DB sync

curl -s -X POST "http://localhost:4096/copilot" -H "content-type: application/json" -H "Authorization: Bearer caller-jwt" -d '{"q":1}'
{"path": "/api/copilot", "authorization": "Bearer caller-jwt"} [HTTP 200]

After further DB syncs

curl -s -X POST "http://localhost:4096/copilot" -H "content-type: application/json" -H "Authorization: Bearer caller-jwt" -d '{"q":1}'
{"path": "/api/copilot", "authorization": "Bearer caller-jwt"} [HTTP 200]
curl -s -X POST "http://localhost:4096/envtarget" -H "content-type: application/json" -d '{}'
{"path": "/api/envtarget", "authorization": null} [HTTP 200]

UI create

curl -s -X POST "http://localhost:4096/config/pass_through_endpoint" -H "content-type: application/json" -H "Authorization: Bearer $MASTER_KEY" -d '{"path":"/snowflake","target":"http://127.0.0.1:9081/api/snowflake","auth":false}'
{"endpoints":[{"id":"<id>","path":"/snowflake","target":"http://127.0.0.1:9081/api/snowflake","headers":{},"default_query_params":{},"include_subpath":false,"cost_per_request":0.0,"timeout":null,"auth":false,"guardrails":null,"is_from_config":false,"methods":null}]} [HTTP 200]
curl -s -X POST "http://localhost:4096/snowflake" -H "content-type: application/json" -d '{}'
{"path": "/api/snowflake", "authorization": null} [HTTP 200]

After a DB sync

curl -s -X POST "http://localhost:4096/copilot" -H "content-type: application/json" -H "Authorization: Bearer caller-jwt" -d '{"q":1}'
{"path": "/api/copilot", "authorization": "Bearer caller-jwt"} [HTTP 200]

List pass-throughs (summarized as path, is_from_config, auth)

curl -s "http://localhost:4096/config/pass_through_endpoint" -H "Authorization: Bearer $MASTER_KEY"
/copilot is_from_config=True auth=False
/envtarget is_from_config=True auth=False
/snowflake is_from_config=False auth=False

Type

🐛 Bug Fix

Caveats (if any)

Medium

Low

  • A UI-created auth: false pass-through now serves immediately; before it answered 401 until the next DB sync

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

#43962)

* fix(proxy): restore pre-config-wins handling of pass-through endpoints

Config-wins (#41779) made general_settings.pass_through_endpoints a config-owned key. The DB reader then got the config list back as if it were DB rows, re-registered each entry without forward_headers on every DB sync, and the stripped copy won the route lookup, so a config pass-through with forward_headers: true stopped forwarding Authorization. UI create, update and delete of pass-throughs were also rejected while the config declared any.

This puts pass-throughs back on their pre-#41779 path: the settings store no longer lets the config own the key, the config list is captured env-resolved at load_config, each DB sync merges DB entries with config entries on paths the DB does not declare, and /config/field/info reads the stored rows only. A UI pass-through write re-applies that merge immediately so the config entries stay served until the next sync.

* fix(proxy): keep config pass-throughs in every reload of the merged list

get_config now returns DB pass-throughs plus config ones on other paths,
each DB sync republishes that merged list, and /config/field/info reads
pass_through_endpoints from the DB row so a UI write never drops stored
entries when models are not stored in the DB

* fix(proxy): keep serving pass-throughs while the config file reloads

load_yaml cleared the runtime pass-through list, so auth: false routes
answered 401 while get_config awaited the database

* fix(proxy): read stored pass-throughs from the writer before a UI write

A lagging read replica could return an older list, and the UI create and
edit flows write the whole field back

* fix(proxy): apply config file pass-through auth changes on reload

The kept runtime list was merged as if it were DB entries, so an edited
config entry on the same path was dropped. Merge the stored DB row with
the fresh config instead, and give the field-info test mock a writer

* fix(proxy): keep pass-throughs served while a DB sync reads the database

get_config resets the stored DB rows before reading them again, which
cleared the served pass-through list and made auth: false routes answer
401 for the length of the read

* refactor(proxy): move the settings store reload out of the loop

basedpyright rejects a Final variable assigned inside a loop

(cherry picked from commit 2eb2bf1)
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 0/5

[High risk] Restructures how pass-through endpoints are stored and resolved.

The PR is not safe to merge until live pass-through routes and authentication settings remain aligned across reloads and deletions.

Findings

  1. P1 Security Reload keeps old authentication ▶
  2. P1 Security Same-path methods lose authentication ▶
  3. P1 Security Deleted routes remain registered ▶

Summary

This backport separates config-file pass-through endpoints from the DB settings field and merges the two sources for serving and UI writes. It also adds integration tests for boot, sync, reload, and CRUD behavior. The reload and merge paths still need changes to keep route registration aligned with authentication settings.

Reviews (1) · Last reviewed commit: "fix(proxy): restore pre-config-wins hand..."

@yuneng-berri
yuneng-berri merged commit 61876f2 into rc/1.104.0 Oct 1, 2026
8 of 10 checks passed
@yuneng-berri
yuneng-berri deleted the litellm_backport_lit9020_rc_1_104_0 branch October 1, 2026 18:40
Comment on lines 5193 to +5195
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Reload keeps old authentication When an operator changes a config pass-through from auth: false to auth: true, a YAML reload does not refresh config_passthrough_endpoints. The following DB sync re-registers the old definition, so requests can continue reaching the route without the newly required authentication.

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:

Comment on lines +5073 to +5075
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Same-path methods lose authentication If a DB endpoint serves POST with auth: false and a YAML endpoint serves GET with auth: true on 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

Comment on lines +7652 to +7653
if "pass_through_endpoints" not in self.settings:
self._publish_pass_through_endpoints(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Deleted routes remain registered If supported_db_objects excludes pass-through endpoints, a DB sync that removes the pass-through field reaches this branch but skips the later route initialization. This branch changes settings without removing registered routes, so a deleted endpoint that previously had auth: false can remain reachable.

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:

if isinstance(yaml_endpoints, list)
else None
)
_reload_settings_store(section, store, config.get(section))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Reloads retain stale pass-through authentication

config_passthrough_endpoints is now updated only by load_config(), not by the get_config() calls used during database reconciliation. If an operator changes a YAML route from auth: false to auth: true while the stored pass-through field contains a list (including []), _serve_pass_through_endpoints() restores the startup entry in both the live settings and route registry, allowing unauthenticated callers to continue invoking the route.

Refresh the YAML-only endpoint snapshot on reload before publishing and registering the merged list, retaining the previous serving state separately during database reads.

@veria-ai

veria-ai Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR backports #43962 to rc/1.104.0, changing how the proxy maintains and restores pass-through endpoint configuration during configuration loading and database reconciliation.

One issue remains open: configuration reloads can preserve an outdated pass-through authentication setting. When an operator changes a YAML route from unauthenticated to authenticated and the stored pass-through field contains a list, reconciliation can restore the startup configuration, allowing unauthenticated callers to continue invoking that route.

Open issues (1)

Fixed/addressed: 0 · PR risk: 7/10

This branch is waiting to be deployed

1 waiting deployment
e2e-changed — f0690d7f Waiting Oct 1, 2026 by yuneng-berri via oauth #2361
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant