Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 98 additions & 1 deletion packages/sie_gateway/src/handlers/decisions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ fn normalize_request(parsed: &Value) -> Result<(Value, Vec<Value>), Invalid> {
native.insert(format!("question_{index}"), normalized);
}
let selectors =
super::lora::selectors(body).map_err(|message| invalid(message, "lora_adapter"))?;
super::lora::selectors(body).map_err(|error| invalid(error.message, error.field))?;
let mut request = json!({"model": model, "state": body["input"], "questions": native});
request.as_object_mut().unwrap().extend(selectors);
Ok((request, questions.clone()))
Expand Down Expand Up @@ -664,6 +664,103 @@ mod tests {
assert!(normalize_request(&conflict).is_err());
}

async fn assert_lora_error_fields(path: &str, base: Value, status: StatusCode) {
let gateway = TestGateway::new(&[LOCAL_DECISION_MODEL]).await;
let router = create_router(
Arc::clone(&gateway.state),
Arc::clone(&gateway.state.config),
);
let mut cases = Vec::new();
for field in ["lora", "lora_id", "lora_adapter"] {
for value in [
json!(false),
json!(1),
json!([]),
json!({}),
json!(""),
json!(" \t"),
] {
cases.push((
json!({field: value}),
field,
format!("'{field}' must be a non-empty string or null"),
));
}
}
for (first, second) in [
("lora_adapter", "lora"),
("lora_adapter", "lora_id"),
("lora", "lora_id"),
] {
for (left, right) in [
(Value::Null, json!("private-selection-a")),
(json!("private-selection-a"), Value::Null),
(json!("private-selection-a"), json!("private-selection-b")),
] {
cases.push((
json!({first: left, second: right}),
second,
"LoRA selector fields must agree, including explicit null values".into(),
));
}
}
for (selectors, field, message) in cases {
let mut payload = base.clone();
payload
.as_object_mut()
.unwrap()
.extend(selectors.as_object().unwrap().clone());
let request = Request::builder()
.method(Method::POST)
.uri(path)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.unwrap();
let response = router.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), status, "{path}: {selectors}");
let body = body_json(response).await;
if path == "/v1/decisions" {
assert_eq!(
body["error"],
json!({
"message": message, "param": field,
"type": "invalid_request_error", "code": "invalid_request",
}),
"{selectors}"
);
} else {
assert_eq!(
body["detail"][0]["loc"],
json!(["body", field]),
"{selectors}"
);
assert_eq!(body["detail"][0]["msg"], message, "{selectors}");
}
assert!(!body.to_string().contains("private-selection"));
assert!(gateway.dispatcher.dispatched().is_empty());
}
}

#[tokio::test]
async fn registered_decisions_route_identifies_invalid_lora_fields() {
assert_lora_error_fields(
"/v1/decisions",
predicate_request(),
StatusCode::BAD_REQUEST,
)
.await;
}

#[tokio::test]
async fn registered_systemone_route_identifies_invalid_lora_fields() {
assert_lora_error_fields(
"/v1/systemone",
json!({"model": "acme/decide", "state": "Continue?", "questions": {"q": {"type": "noul"}}}),
StatusCode::UNPROCESSABLE_ENTITY,
)
.await;
}

#[test]
fn shared_fixtures_pin_request_and_response_parity() {
for case in fixture()["requests"].as_array().unwrap() {
Expand Down
26 changes: 21 additions & 5 deletions packages/sie_gateway/src/handlers/lora.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,31 @@ use serde_json::{Map, Value};

pub(crate) const FIELDS: [&str; 3] = ["lora_adapter", "lora", "lora_id"];

#[derive(Debug)]
pub(crate) struct SelectorError {
pub field: &'static str,
pub message: String,
}

/// Retain the supplied spellings so legacy source-only admission cannot be lost.
pub(crate) fn selectors(body: &Map<String, Value>) -> Result<Map<String, Value>, String> {
pub(crate) fn selectors(body: &Map<String, Value>) -> Result<Map<String, Value>, SelectorError> {
let mut selected = None;
let mut fields = Map::new();
for field in FIELDS {
let Some(value) = body.get(field) else {
continue;
};
if !value.is_null() && !value.as_str().is_some_and(|s| !s.trim().is_empty()) {
return Err(format!("'{field}' must be a non-empty string or null"));
return Err(SelectorError {
field,
message: format!("'{field}' must be a non-empty string or null"),
});
}
if selected.is_some_and(|prior| prior != value) {
return Err("LoRA selector fields must agree, including explicit null values".into());
return Err(SelectorError {
field,
message: "LoRA selector fields must agree, including explicit null values".into(),
});
}
selected = Some(value);
fields.insert(field.into(), value.clone());
Expand All @@ -27,7 +39,9 @@ pub(crate) fn selectors(body: &Map<String, Value>) -> Result<Map<String, Value>,
pub(crate) fn validate_options(options: Option<&Value>) -> Result<(), String> {
match options {
None | Some(Value::Null) => Ok(()),
Some(Value::Object(options)) => selectors(options).map(|_| ()),
Some(Value::Object(options)) => selectors(options)
.map(|_| ())
.map_err(|error| error.message),
_ => Err("'options' must be an object or null".into()),
}
}
Expand Down Expand Up @@ -61,7 +75,9 @@ pub(crate) fn validate_options_rmpv(options: Option<&rmpv::Value>) -> Result<(),
return Err(format!("duplicate LoRA selector '{key}'"));
}
}
selectors(&selected).map(|_| ())
selectors(&selected)
.map(|_| ())
.map_err(|error| error.message)
}

#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion packages/sie_gateway/src/handlers/systemone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ pub(crate) fn normalize_request(parsed: &Value) -> Result<SystemOneRequest, Inva
normalized.insert(qid.clone(), Value::Object(question));
}
let lora_options =
super::lora::selectors(body).map_err(|message| invalid(&["lora_adapter"], message))?;
super::lora::selectors(body).map_err(|error| invalid(&[error.field], error.message))?;
Ok(SystemOneRequest {
model,
item,
Expand Down
68 changes: 60 additions & 8 deletions packages/sie_server/src/sie_server/core/runtime_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@
import asyncio
import logging
import math
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, cast

from sie_server.config.model import lora_entry_ref
from sie_server.types.inputs import InvalidInputError
from sie_server.types.lora import InvalidLoraSelectionError, split_lora_selection
from sie_server.types.lora import (
LORA_SELECTION_FIELDS,
InvalidLoraSelectionError,
LoraSelection,
split_lora_selection,
)
from sie_server.types.overflow_policy import VALID_OVERFLOW_POLICIES

if TYPE_CHECKING:
Expand Down Expand Up @@ -79,6 +85,11 @@ class InvalidLoraOptionError(InvalidInputError):
"""A request's LoRA aliases have invalid or conflicting values."""


def _profile_lora_aliases(resolved: ResolvedProfile) -> dict[str, str]:
paths = resolved.loadtime.get("lora_paths")
return {name: lora_entry_ref(ref)[0] for name, ref in paths.items() if ref} if isinstance(paths, Mapping) else {}


def _check_declared_lora(
config: ModelConfig, resolved: ResolvedProfile, profile_name: str, request_options: dict[str, Any]
) -> None:
Expand All @@ -92,21 +103,59 @@ def _check_declared_lora(
Raises:
UndeclaredLoraError: Either option is not null and not one of those names.
"""
ids = resolved.lora_ids()
names = ids | _profile_lora_aliases(resolved).keys()
for key, declared in (
("lora_id", resolved.lora_ids),
("lora", resolved.lora_names),
("lora_adapter", resolved.lora_names),
("lora_id", ids),
("lora", names),
("lora_adapter", names),
):
value = request_options.get(key)
if value is None:
continue
if not isinstance(value, str) or value not in declared():
if not isinstance(value, str) or value not in declared:
raise UndeclaredLoraError(
f"'options.{key}' must name a LoRA adapter that profile '{profile_name}' of model "
f"'{config.sie_id}' declares; {value!r} is not one of them."
)


def _profile_lora_selection(resolved: ResolvedProfile) -> LoraSelection:
"""Compare profile aliases by source while preserving the served selector.

Unlike request aliases, a profile's legacy ``lora_id`` also declares a
source to load. ``lora_adapter`` / ``lora`` select that source by served
name, or explicitly select the base model with null.
"""
selections = [
split_lora_selection({field: resolved.runtime[field]})[0]
for field in LORA_SELECTION_FIELDS
if field in resolved.runtime
]
if not selections:
return LoraSelection(provided=False, value=None, fields=())

aliases = _profile_lora_aliases(resolved)
declared = resolved.lora_ids() | aliases.keys()
sources: list[str | None] = []
for selection in selections:
value = selection.value
field = selection.fields[0]
if value is not None and field != "lora_id" and value not in declared:
raise InvalidLoraSelectionError(f"Profile '{field}' must select a declared LoRA adapter")
sources.append(aliases.get(value, value) if value is not None and field != "lora_id" else value)

selected = selections[0]
for selection, source in zip(selections[1:], sources[1:], strict=True):
# A source declaration is not a competing base selection; likewise,
# a declared source must not override an explicit base default.
if selection.fields == ("lora_id",) and (selected.value is None or selection.value is None):
continue
if source != sources[0]:
raise InvalidLoraSelectionError("Profile LoRA defaults must resolve to the same declared adapter")
return selected


def merge_runtime_options_with_profile(
config: ModelConfig,
request_options: dict[str, Any] | None,
Expand All @@ -123,11 +172,14 @@ def merge_runtime_options_with_profile(
selection, request = split_lora_selection(request_options or {})
except InvalidLoraSelectionError as exc:
raise InvalidLoraOptionError(str(exc)) from exc
defaults, merged = split_lora_selection(resolved.runtime)
merged = {key: value for key, value in resolved.runtime.items() if key not in LORA_SELECTION_FIELDS}
if selection.provided:
_check_declared_lora(config, resolved, request.get("profile") or "default", request_options or {})
else:
selection = defaults
try:
selection = _profile_lora_selection(resolved)
except InvalidLoraSelectionError as exc:
raise InvalidLoraOptionError(str(exc)) from exc
merged |= {key: value for key, value in request.items() if key != "profile"}
if selection.provided:
merged["lora"] = selection.value
Expand Down
Loading
Loading