Skip to content

Commit d183404

Browse files
krisztian-gajdarKrisztian Gajdar
andauthored
fix(server): load only the LoRA adapters a model declares (#752)
* fix(server): load only the LoRA adapters a model declares A request's options.lora and options.lora_id now have to name a LoRA that the model's profiles declare (a lora_paths source or served name, a runtime.lora_id or a runtime.lora). Any other value is a 400 on the direct and queued encode, extract and score paths, and the gateway refuses it before dispatch, as it already does for generation's lora_adapter. The registry refuses an undeclared id for every caller. The PEFT mixin also refuses adapter configs whose loading changes the shared base model: an init_lora_weights other than true, false, gaussian, eva or orthogonal, layer_replication, megatron_config, and non-LoRA PEFT types. A batch whose LoRA cannot be made active now fails its requests instead of leaving them pending. LoRA loads and unloads run while no batch of the model is in flight, and a LoRA evicted while it loaded is unloaded once its load finishes. * fix(server): accept only declared LoRA ids in options.lora_id options.lora_id names a LoRA to load, so the request merge and the gateway now accept only a declared id there, as the registry does. A served name stays valid in options.lora, which selects a loaded LoRA. A LoRA that is already loaded is ready without waiting for the model's LoRA lock, which an eviction can hold while a batch finishes. * fix(server): load a LoRA while the model's worker is stopped A stopped worker has shut its inference executor down, so a LoRA load or unload that waits for its dispatch lock now runs on the default executor instead of failing. * fix(server): fetch LoRAs outside inference and keep loaded adapters out of errors A request-time LoRA load now downloads its files outside inference: a Hub LoRA is fetched once, at one commit, with only adapter_config.json and adapter_model.safetensors, and only the in-memory attach from that local snapshot waits for the model's in-flight batch. A failed load is held back with a doubling backoff from 5 s to 5 min, answered with a retryable 503 LORA_LOADING and Retry-After instead of being fetched again on every request. The adapter config and the weights file are both validated on that snapshot: weights other than a LoRA's own parameters, and weights shipped only as .bin, are refused, because PEFT loads any other tensor name into the model it wraps. A batch whose LoRA cannot be made active fails the same way on the pre-formed path the queue and the sidecar use as on the direct path. When the registry can load that LoRA, it starts the load and the requests get the retryable 503; otherwise they get a 400. Selecting a LoRA while none is loaded is refused instead of serving the base model. No adapter's error text lists the loaded or served adapters. A request's LoRA options are now checked against the profile it selects, not every profile of the model, on the server and the gateway. --------- Co-authored-by: Krisztian Gajdar <krisztian@superlinked.com>
1 parent 09b62a3 commit d183404

25 files changed

Lines changed: 1898 additions & 157 deletions

‎packages/sie_gateway/src/handlers/proxy.rs‎

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4498,6 +4498,24 @@ async fn queue_mode_proxy(
44984498
return response;
44994499
}
45004500

4501+
if matches!(endpoint, "encode" | "extract" | "score") {
4502+
if let Some((key, value)) = undeclared_lora_option(params.options.as_ref(), || {
4503+
state.model_registry.get_model_info(model)
4504+
}) {
4505+
return endpoint_error_response(
4506+
endpoint,
4507+
StatusCode::BAD_REQUEST,
4508+
err_code::INVALID_REQUEST,
4509+
oai_type::INVALID_REQUEST,
4510+
oai_code::UNKNOWN_LORA_ADAPTER,
4511+
Some(key),
4512+
format!(
4513+
"'{key}' must name a LoRA adapter that model '{display_model}' declares; {value} is not one of them."
4514+
),
4515+
);
4516+
}
4517+
}
4518+
45014519
// Generate has its own publish + result-collection path
45024520
// (streaming chunk aggregation instead of one-shot WorkResult fan-in).
45034521
if endpoint == "generate" {
@@ -11733,6 +11751,79 @@ pub(crate) fn validate_lora_for_profile(
1173311751
}
1173411752
}
1173511753

11754+
/// The caller's ``options.lora`` or ``options.lora_id`` as ``(param, value)``
11755+
/// when it is set and names no LoRA that the selected profile
11756+
/// (``options.profile``, else ``default``) of the model ``entry`` returns
11757+
/// declares. ``options.lora_id`` names a LoRA to load: a
11758+
/// ``loadtime.lora_paths`` source or a ``runtime.lora_id``. ``options.lora``
11759+
/// selects a loaded LoRA, so a ``lora_paths`` served name and a
11760+
/// ``runtime.lora`` also qualify. ``entry`` is called only when the options
11761+
/// select a LoRA; a model it does not return is not checked.
11762+
pub(crate) fn undeclared_lora_option(
11763+
options: Option<&Value>,
11764+
entry: impl FnOnce() -> Option<crate::types::model::ModelEntry>,
11765+
) -> Option<(&'static str, Value)> {
11766+
let options = options?.as_object()?;
11767+
let requested: Vec<(&'static str, bool, &Value)> = [
11768+
("options.lora", "lora", true),
11769+
("options.lora_id", "lora_id", false),
11770+
]
11771+
.into_iter()
11772+
.filter_map(|(param, key, served_names)| {
11773+
let value = options.get(key).filter(|value| !value.is_null())?;
11774+
Some((param, served_names, value))
11775+
})
11776+
.collect();
11777+
if requested.is_empty() {
11778+
return None;
11779+
}
11780+
let entry = entry()?;
11781+
let profile_name = options
11782+
.get("profile")
11783+
.and_then(Value::as_str)
11784+
.unwrap_or("default");
11785+
let adapter_options = entry
11786+
.profile_configs
11787+
.get(profile_name)
11788+
.and_then(|profile| profile.adapter_options.as_ref());
11789+
requested
11790+
.into_iter()
11791+
.find(|(_, served_names, value)| {
11792+
!value.as_str().is_some_and(|name| {
11793+
adapter_options
11794+
.is_some_and(|options| profile_declares_lora(options, name, *served_names))
11795+
})
11796+
})
11797+
.map(|(param, _, value)| (param, value.clone()))
11798+
}
11799+
11800+
fn profile_declares_lora(adapter_options: &Value, name: &str, served_names: bool) -> bool {
11801+
let runtime = adapter_options.get("runtime");
11802+
let runtime_names = |key: &str| {
11803+
runtime
11804+
.and_then(|runtime| runtime.get(key))
11805+
.and_then(Value::as_str)
11806+
== Some(name)
11807+
};
11808+
if runtime_names("lora_id") || (served_names && runtime_names("lora")) {
11809+
return true;
11810+
}
11811+
match adapter_options
11812+
.get("loadtime")
11813+
.and_then(|loadtime| loadtime.get("lora_paths"))
11814+
{
11815+
Some(Value::Object(paths)) => paths.iter().any(|(served, source)| {
11816+
(served_names && served == name)
11817+
|| source
11818+
.as_str()
11819+
.or_else(|| source.get("id").and_then(Value::as_str))
11820+
== Some(name)
11821+
}),
11822+
Some(Value::Array(paths)) => paths.iter().any(|path| path.as_str() == Some(name)),
11823+
_ => false,
11824+
}
11825+
}
11826+
1173611827
fn parse_model_spec(spec: &str) -> (String, String) {
1173711828
if let Some(idx) = spec.find(":/") {
1173811829
(spec[..idx].to_string(), spec[idx + 2..].to_string())
@@ -29468,6 +29559,204 @@ mod tests {
2946829559
assert!(v["choices"][0].get("delta").is_none());
2946929560
}
2947029561

29562+
// ── Declared LoRA selection on encode, extract and score ───────
29563+
29564+
const LORA_MODEL: &str = "\
29565+
sie_id: acme/lora
29566+
hf_id: acme/lora
29567+
tasks:
29568+
encode:
29569+
dense:
29570+
dim: 2
29571+
score: {}
29572+
extract: {}
29573+
profiles:
29574+
default:
29575+
adapter_path: sie_server.adapters.bert_flash:BertFlashAdapter
29576+
max_batch_tokens: 4096
29577+
banking:
29578+
extends: default
29579+
adapter_options:
29580+
runtime:
29581+
lora_id: org/banking-lora
29582+
tuned:
29583+
extends: default
29584+
adapter_options:
29585+
runtime:
29586+
lora: org/tuned-lora
29587+
served:
29588+
extends: default
29589+
adapter_options:
29590+
loadtime:
29591+
lora_paths:
29592+
legal: org/legal-lora
29593+
pinned:
29594+
id: org/pinned-lora
29595+
revision: 0123456789abcdef0123456789abcdef01234567
29596+
listed:
29597+
extends: default
29598+
adapter_options:
29599+
loadtime:
29600+
lora_paths:
29601+
- org/listed-lora
29602+
";
29603+
29604+
#[tokio::test]
29605+
async fn undeclared_lora_option_accepts_only_what_the_selected_profile_declares() {
29606+
let gateway = TestGateway::new(&[LORA_MODEL]).await;
29607+
let entry = gateway
29608+
.state
29609+
.model_registry
29610+
.get_model_info("acme/lora")
29611+
.expect("model entry");
29612+
29613+
for (profile, name, keys) in [
29614+
("banking", "org/banking-lora", &["lora", "lora_id"][..]),
29615+
("served", "org/legal-lora", &["lora", "lora_id"][..]),
29616+
("served", "org/pinned-lora", &["lora", "lora_id"][..]),
29617+
("listed", "org/listed-lora", &["lora", "lora_id"][..]),
29618+
("tuned", "org/tuned-lora", &["lora"][..]),
29619+
("served", "legal", &["lora"][..]),
29620+
("served", "pinned", &["lora"][..]),
29621+
] {
29622+
for key in keys {
29623+
let options = json!({ "profile": profile, *key: name });
29624+
assert_eq!(
29625+
undeclared_lora_option(Some(&options), || Some(entry.clone())),
29626+
None,
29627+
"{options}"
29628+
);
29629+
}
29630+
}
29631+
let not_looked_up = || -> Option<crate::types::model::ModelEntry> {
29632+
panic!("no LoRA is selected, so the model is not looked up")
29633+
};
29634+
assert_eq!(
29635+
undeclared_lora_option(Some(&json!({"lora": null, "lora_id": null})), not_looked_up),
29636+
None
29637+
);
29638+
assert_eq!(
29639+
undeclared_lora_option(Some(&json!({"profile": "banking"})), not_looked_up),
29640+
None
29641+
);
29642+
assert_eq!(undeclared_lora_option(None, not_looked_up), None);
29643+
assert_eq!(
29644+
undeclared_lora_option(Some(&json!({"lora": "attacker/lora"})), || None),
29645+
None
29646+
);
29647+
29648+
for (options, expected) in [
29649+
(
29650+
json!({"lora_id": "attacker/lora"}),
29651+
("options.lora_id", json!("attacker/lora")),
29652+
),
29653+
(
29654+
json!({"lora": "/srv/any/dir"}),
29655+
("options.lora", json!("/srv/any/dir")),
29656+
),
29657+
(
29658+
json!({"lora_id": "org/banking-lora"}),
29659+
("options.lora_id", json!("org/banking-lora")),
29660+
),
29661+
(
29662+
json!({"profile": "served", "lora_id": "org/banking-lora"}),
29663+
("options.lora_id", json!("org/banking-lora")),
29664+
),
29665+
(
29666+
json!({"profile": "missing", "lora": "legal"}),
29667+
("options.lora", json!("legal")),
29668+
),
29669+
(
29670+
json!({"profile": "served", "lora_id": "legal"}),
29671+
("options.lora_id", json!("legal")),
29672+
),
29673+
(
29674+
json!({"profile": "tuned", "lora_id": "org/tuned-lora"}),
29675+
("options.lora_id", json!("org/tuned-lora")),
29676+
),
29677+
(json!({"lora": ""}), ("options.lora", json!(""))),
29678+
(json!({"lora": 42}), ("options.lora", json!(42))),
29679+
(
29680+
json!({"profile": "served", "lora": "legal", "lora_id": "attacker/lora"}),
29681+
("options.lora_id", json!("attacker/lora")),
29682+
),
29683+
] {
29684+
assert_eq!(
29685+
undeclared_lora_option(Some(&options), || Some(entry.clone())),
29686+
Some(expected),
29687+
"{options}"
29688+
);
29689+
}
29690+
}
29691+
29692+
#[tokio::test]
29693+
async fn an_undeclared_lora_is_refused_on_encode_extract_and_score_before_dispatch() {
29694+
let gateway = TestGateway::new(&[LORA_MODEL]).await;
29695+
gateway.add_worker("local-1", LOCAL_LANE, &[]).await;
29696+
let requests = |options: Value| {
29697+
[
29698+
(
29699+
"encode",
29700+
json!({"items": [{"text": "x"}], "params": {"options": options.clone()}}),
29701+
),
29702+
(
29703+
"extract",
29704+
json!({"items": [{"text": "x"}], "params": {"labels": ["a"], "options": options.clone()}}),
29705+
),
29706+
(
29707+
"score",
29708+
json!({"query": {"text": "q"}, "items": [{"text": "x"}], "options": options}),
29709+
),
29710+
]
29711+
};
29712+
29713+
for (endpoint, body) in requests(json!({"lora_id": "attacker/lora"})) {
29714+
let response = proxy_request(
29715+
State(Arc::clone(&gateway.state)),
29716+
json_request(&format!("/v1/{endpoint}/acme/lora"), body),
29717+
endpoint,
29718+
)
29719+
.await;
29720+
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{endpoint}");
29721+
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
29722+
let value: Value = serde_json::from_slice(&body).unwrap();
29723+
assert_eq!(value["detail"]["code"], "INVALID_REQUEST", "{endpoint}");
29724+
assert!(
29725+
value["detail"]["message"]
29726+
.as_str()
29727+
.is_some_and(|message| message.contains("attacker/lora")),
29728+
"{endpoint}: {value}"
29729+
);
29730+
}
29731+
assert_eq!(gateway.dispatcher.dispatched(), Vec::new());
29732+
29733+
let msgpack = Request::builder()
29734+
.uri("/v1/encode/acme/lora")
29735+
.header("content-type", "application/msgpack")
29736+
.body(Body::from(
29737+
rmp_serde::to_vec_named(&json!({
29738+
"items": [{"text": "x"}],
29739+
"params": {"options": {"lora_id": "attacker/lora"}}
29740+
}))
29741+
.unwrap(),
29742+
))
29743+
.unwrap();
29744+
let response = proxy_request(State(Arc::clone(&gateway.state)), msgpack, "encode").await;
29745+
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "msgpack encode");
29746+
assert_eq!(gateway.dispatcher.dispatched(), Vec::new());
29747+
29748+
for (endpoint, body) in requests(json!({"profile": "served", "lora": "legal"})) {
29749+
let response = proxy_request(
29750+
State(Arc::clone(&gateway.state)),
29751+
json_request(&format!("/v1/{endpoint}/acme/lora"), body),
29752+
endpoint,
29753+
)
29754+
.await;
29755+
assert_ne!(response.status(), StatusCode::BAD_REQUEST, "{endpoint}");
29756+
}
29757+
assert_eq!(gateway.dispatcher.dispatched().len(), 3);
29758+
}
29759+
2947129760
// ── M10: per-profile LoRA adapter validation ───────────────────
2947229761
//
2947329762
// ``validate_lora_for_profile`` is the seam the chat and generate

‎packages/sie_server/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1060,7 +1060,7 @@ auto-retries; see `packages/sie_sdk/README.md` for client-side controls.
10601060
| `SIE_MAX_BATCH_REQUESTS` | `64` | Maximum number of items per batched inference call. |
10611061
| `SIE_MAX_BATCH_WAIT_MS` | `15.0` | Initial value for the adaptive first-request batch timeout. At runtime the PI batching controller steers it between `SIE_ADAPTIVE_BATCHING__MIN_WAIT_MS` and `SIE_ADAPTIVE_BATCHING__MAX_WAIT_MS`, so it is a starting point rather than a fixed wait. |
10621062
| `SIE_MAX_CONCURRENT_REQUESTS` | `512` | Per-worker queue size; admission control returns `QUEUE_FULL` above this. |
1063-
| `SIE_MAX_LORAS_PER_MODEL` | `10` | Maximum concurrent LoRA adapters per base model. |
1063+
| `SIE_MAX_LORAS_PER_MODEL` | `10` | Maximum concurrent LoRA adapters per base model. A request loads or selects only the adapters its selected profile declares. `options.lora` or `options.lora_id` naming any other is rejected with HTTP 400: `INVALID_INPUT` from this server, and `INVALID_REQUEST` from the gateway (code `unknown_lora_adapter` where it answers in the OpenAI error envelope). |
10641064
| `SIE_MAX_ITEM_TEXT_BYTES` | `2097152` (2 MiB) | Most bytes of UTF-8 one encode, score, or extract item may carry in its `text` and `metadata` together. A larger item is rejected with `INVALID_INPUT` (HTTP 400) before it is tokenized. Generation prompts are not items and are not affected. |
10651065

10661066
### Compute & precision

‎packages/sie_server/src/sie_server/adapters/base.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,26 @@ def supports_hot_lora_reload(self) -> bool:
591591
"""
592592
return False
593593

594-
def load_lora(self, lora_path: str, revision: str | None = None) -> int:
594+
def fetch_lora(self, lora_path: str, revision: str | None = None) -> str:
595+
"""Bring a LoRA adapter's files to one local directory, outside inference.
596+
597+
Hot-reload adapters (PEFT) download and validate here, so that
598+
:meth:`load_lora` only attaches from local files.
599+
600+
Args:
601+
lora_path: HuggingFace path (e.g., "org/lora-name") or local path.
602+
revision: Pinned 40-hex commit SHA, or ``None`` for the default branch.
603+
604+
Returns:
605+
The local directory to pass to :meth:`load_lora` as ``source``.
606+
607+
Raises:
608+
NotImplementedError: If adapter doesn't support LoRA.
609+
"""
610+
msg = f"{self.__class__.__name__} does not support LoRA (supports_lora=False)"
611+
raise NotImplementedError(msg)
612+
613+
def load_lora(self, lora_path: str, revision: str | None = None, *, source: str | None = None) -> int:
595614
"""Load a LoRA adapter.
596615
597616
This is called by the LoRA manager to load a new adapter. The adapter
@@ -605,6 +624,7 @@ def load_lora(self, lora_path: str, revision: str | None = None) -> int:
605624
revision: Pinned 40-hex commit SHA from the config's
606625
``loadtime.lora_paths`` dict form (#2113); ``None`` resolves
607626
the Hub's default branch, as before.
627+
source: Local directory from :meth:`fetch_lora`; ``None`` fetches first.
608628
609629
Returns:
610630
Memory usage of the loaded LoRA in bytes.

‎packages/sie_server/src/sie_server/adapters/lora.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@ def supports_hot_lora_reload(self) -> bool:
2525
if the load blocks all requests (SGLang HTTP).
2626
"""
2727

28-
def load_lora(self, lora_path: str, revision: str | None = None) -> int:
29-
"""Load a LoRA adapter (optionally at a pinned 40-hex commit SHA);
30-
return its memory usage in bytes.
28+
def fetch_lora(self, lora_path: str, revision: str | None = None) -> str:
29+
"""Bring a LoRA adapter's files to one local directory and return it.
30+
31+
Hot-reload adapters do their network and disk I/O here, outside inference.
32+
"""
33+
34+
def load_lora(self, lora_path: str, revision: str | None = None, *, source: str | None = None) -> int:
35+
"""Load a LoRA adapter (optionally at a pinned 40-hex commit SHA), from
36+
``source`` when :meth:`fetch_lora` already brought it local; return its
37+
memory usage in bytes.
3138
"""
3239

3340
def unload_lora(self, lora_name: str) -> None:

0 commit comments

Comments
 (0)