Skip to content

Commit 11ebc98

Browse files
percontationAwni Hannun
andauthored
server: support chat_template_kwargs and top_logprobs (ml-explore#829)
* server: support chat_template_kwargs and top_logprobs * Adds support for clients sending "chat_template_kwargs", matching other open source LLM servers. This is gated behind `--trust-client-kwargs` because transformers does not provide any safe way to do this. * changes the server's logprobs response to better match the OpenAI chat api & other open source servers. * server: fix response when handling exceptions * server: --client-chat-template-args whitelist * simplify * comment --------- Co-authored-by: Awni Hannun <awni@apple.com>
1 parent 1630f9b commit 11ebc98

1 file changed

Lines changed: 65 additions & 42 deletions

File tree

mlx_lm/server.py

Lines changed: 65 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -341,8 +341,10 @@ class GenerationArguments:
341341

342342
max_tokens: int
343343
num_draft_tokens: int
344-
logprobs: int
344+
logprobs: bool
345+
top_logprobs: int
345346
seed: Optional[int]
347+
chat_template_kwargs: Optional[Dict[str, Any]]
346348

347349

348350
@dataclass
@@ -382,7 +384,7 @@ class Response:
382384
token: int
383385
logprob: float
384386
finish_reason: Optional[str]
385-
top_tokens: Optional[Tuple[int, float]]
387+
top_tokens: Tuple[Dict[str, Any]]
386388

387389

388390
class TimeBudget:
@@ -557,6 +559,20 @@ def _make_logits_processors(args):
557559
)
558560

559561

562+
def _format_top_logprobs(logprobs, top_logprobs, tokenizer) -> Tuple[Dict[str, Any]]:
563+
"""Returns info dicts for the top `top_logprobs` tokens from `logprobs`"""
564+
if top_logprobs <= 0:
565+
return ()
566+
sorted_indices = mx.argpartition(-logprobs, kth=top_logprobs - 1)
567+
top_indices = sorted_indices[:top_logprobs].tolist()
568+
top_logprobs = logprobs[top_indices].tolist()
569+
txts = tokenizer.convert_ids_to_tokens(top_indices)
570+
return tuple(
571+
{"id": i, "token": s, "logprob": g}
572+
for i, s, g in zip(top_indices, txts, top_logprobs)
573+
)
574+
575+
560576
class ResponseGenerator:
561577
def __init__(self, model_provider: ModelProvider, prompt_cache: LRUPromptCache):
562578
self.model_provider = model_provider
@@ -625,7 +641,7 @@ def _share_request(self, request):
625641
rq = request[0] if request is not None else Queue()
626642
return rq, *shareable
627643

628-
def _tokenize(self, tokenizer, request):
644+
def _tokenize(self, tokenizer, request, args):
629645
if request.request_type == "chat":
630646
messages = request.messages
631647
tools = request.tools
@@ -640,12 +656,16 @@ def _tokenize(self, tokenizer, request):
640656
"https://github.com/ml-explore/mlx-lm/issues"
641657
)
642658

659+
chat_template_args = self.model_provider.cli_args.chat_template_args
660+
if args.chat_template_kwargs:
661+
chat_template_args = chat_template_args.copy()
662+
chat_template_args.update(args.chat_template_kwargs)
643663
return tokenizer.apply_chat_template(
644664
messages,
645665
tools=tools,
646666
add_generation_prompt=True,
647667
tokenize=True,
648-
**self.model_provider.cli_args.chat_template_args,
668+
**chat_template_args,
649669
)
650670
else:
651671
return tokenizer.encode(convert_chat(messages, role_mapping))
@@ -708,7 +728,12 @@ def progress_callback(info):
708728
and current_model == args.model
709729
and is_batchable
710730
):
711-
prompt = self._tokenize(current_tokenizer, request)
731+
try:
732+
prompt = self._tokenize(current_tokenizer, request, args)
733+
except Exception as e:
734+
rqueue.put(e)
735+
continue
736+
712737
ctx = GenerationContext(
713738
has_tool_calling=tokenizer.has_tool_calling,
714739
tool_call_start=tokenizer.tool_call_start,
@@ -810,24 +835,15 @@ def progress_callback(info):
810835
if r.finish_reason != "stop":
811836
result["detokenizer"].add_token(r.token)
812837

813-
top_tokens = None
814-
if args.logprobs > 0:
815-
sorted_indices = mx.argpartition(
816-
-r.logprobs, kth=args.logprobs - 1
817-
)
818-
top_indices = sorted_indices[: args.logprobs]
819-
top_logprobs = r.logprobs[top_indices]
820-
top_token_info = zip(
821-
top_indices.tolist(), top_logprobs.tolist()
822-
)
823-
top_tokens = tuple(top_token_info)
824838
result["rqueue"].put(
825839
Response(
826840
result["detokenizer"].last_segment,
827841
r.token,
828842
r.logprobs[r.token].item(),
829843
r.finish_reason,
830-
top_tokens,
844+
_format_top_logprobs(
845+
r.logprobs, args.top_logprobs, current_tokenizer
846+
),
831847
)
832848
)
833849

@@ -871,7 +887,7 @@ def progress(tokens_processed, tokens_total):
871887
draft_model = self.model_provider.draft_model
872888

873889
# Prepare the prompt
874-
prompt = self._tokenize(tokenizer, request)
890+
prompt = self._tokenize(tokenizer, request, args)
875891

876892
# Start the generation context
877893
ctx = GenerationContext(
@@ -923,23 +939,15 @@ def progress(tokens_processed, tokens_total):
923939
num_draft_tokens=args.num_draft_tokens,
924940
prompt_progress_callback=progress,
925941
):
926-
top_tokens = None
927-
if args.logprobs > 0:
928-
sorted_indices = mx.argpartition(
929-
-gen.logprobs, kth=args.logprobs - 1
930-
)
931-
top_indices = sorted_indices[: args.logprobs]
932-
top_logprobs = gen.logprobs[top_indices]
933-
top_token_info = zip(top_indices.tolist(), top_logprobs.tolist())
934-
top_tokens = tuple(top_token_info)
935-
936942
rqueue.put(
937943
Response(
938944
gen.text,
939945
gen.token,
940946
gen.logprobs[gen.token].item(),
941947
gen.finish_reason,
942-
top_tokens,
948+
_format_top_logprobs(
949+
gen.logprobs, args.top_logprobs, tokenizer
950+
),
943951
)
944952
)
945953
cache_key.append(gen.token)
@@ -1052,6 +1060,7 @@ def do_POST(self):
10521060
except json.JSONDecodeError as e:
10531061
logging.error(f"JSONDecodeError: {e} - Raw body: {raw_body.decode()}")
10541062
self._set_completion_headers(400)
1063+
self.end_headers()
10551064
self.wfile.write(
10561065
json.dumps({"error": f"Invalid JSON in request body: {e}"}).encode()
10571066
)
@@ -1088,8 +1097,10 @@ def do_POST(self):
10881097
self.xtc_probability = self.body.get("xtc_probability", 0.0)
10891098
self.xtc_threshold = self.body.get("xtc_threshold", 0.0)
10901099
self.logit_bias = self.body.get("logit_bias", None)
1091-
self.logprobs = self.body.get("logprobs", -1)
1100+
self.logprobs = self.body.get("logprobs", False)
1101+
self.top_logprobs = self.body.get("top_logprobs", -1)
10921102
self.seed = self.body.get("seed", None)
1103+
self.chat_template_kwargs = self.body.get("chat_template_kwargs")
10931104
self.validate_model_parameters()
10941105

10951106
# Get stop sequences
@@ -1132,9 +1143,12 @@ def validate_model_parameters(self):
11321143
):
11331144
raise ValueError("repetition_penalty must be a non-negative float")
11341145

1135-
if self.logprobs != -1 and not (0 < self.logprobs <= 10):
1146+
if not isinstance(self.logprobs, bool):
1147+
raise ValueError("logprobs must be a boolean")
1148+
1149+
if self.top_logprobs != -1 and not (0 < self.top_logprobs <= 10):
11361150
raise ValueError(
1137-
f"logprobs must be between 1 and 10 but got {self.logprobs:,}"
1151+
f"top_logprobs must be between 1 and 10 but got {self.top_logprobs:,}"
11381152
)
11391153

11401154
if (
@@ -1174,7 +1188,7 @@ def generate_response(
11741188
prompt_token_count: Optional[int] = None,
11751189
completion_token_count: Optional[int] = None,
11761190
token_logprobs: Optional[List[float]] = None,
1177-
top_tokens: Optional[List[Dict[int, float]]] = None,
1191+
top_tokens: Optional[List[Tuple[Dict[str, Any]]]] = None,
11781192
tokens: Optional[List[int]] = None,
11791193
tool_calls: Optional[List[str]] = None,
11801194
reasoning_text: Optional[str] = None,
@@ -1193,8 +1207,8 @@ def generate_response(
11931207
response, used to populate the "usage" field (not used when stream).
11941208
token_logprobs (Optional[List[float]]): The log probabilities per token,
11951209
in token order.
1196-
top_tokens (Optional[List[Dict[int, float]]]): List of dictionaries mapping
1197-
tokens to logprobs for the top N tokens at each token position.
1210+
top_tokens (Optional[List[Tuple[Dict[str, Any]]]]): List of outputs from
1211+
_format_top_logprobs, giving info on the top N tokens at each token position.
11981212
tokens (Optional[List[int]]): List of tokens to return with logprobs structure
11991213
tool_calls (Optional[List[str]]): List of tool calls.
12001214
reasoning_text (Optional[str]): The reasoning text generated by the model.
@@ -1222,11 +1236,17 @@ def generate_response(
12221236
],
12231237
}
12241238

1225-
if token_logprobs or top_logprobs or tokens:
1239+
if top_logprobs:
1240+
response["choices"][0]["logprobs"] = {
1241+
"content": [
1242+
dict(i[0], top_logprobs=i) if i else {} for i in top_logprobs
1243+
]
1244+
}
1245+
elif token_logprobs:
12261246
response["choices"][0]["logprobs"] = {
1227-
"token_logprobs": token_logprobs,
1228-
"top_logprobs": top_logprobs,
1229-
"tokens": tokens,
1247+
"content": [
1248+
dict(id=i, logprob=g) for i, g in zip(tokens, token_logprobs)
1249+
]
12301250
}
12311251

12321252
if not self.stream:
@@ -1294,7 +1314,9 @@ def handle_completion(self, request: CompletionRequest, stop_words: List[str]):
12941314
max_tokens=self.max_tokens,
12951315
num_draft_tokens=self.num_draft_tokens,
12961316
logprobs=self.logprobs,
1317+
top_logprobs=self.top_logprobs,
12971318
seed=self.seed,
1319+
chat_template_kwargs=self.chat_template_kwargs,
12981320
)
12991321

13001322
# Create keepalive callback to send SSE comments during long prompt processing
@@ -1323,7 +1345,7 @@ def keepalive_callback(processed_tokens, total_tokens):
13231345
except Exception as e:
13241346
self._set_completion_headers(404)
13251347
self.end_headers()
1326-
self.wfile.write((f"{e}").encode())
1348+
self.wfile.write(json.dumps({"error": f"{e}"}).encode())
13271349
return
13281350

13291351
# Prepare the headers
@@ -1420,10 +1442,11 @@ def parse_tools(tool_calls):
14201442

14211443
# Save the token and its logprob
14221444
tokens.append(gen.token)
1423-
token_logprobs.append(gen.logprob)
1445+
if args.logprobs:
1446+
token_logprobs.append(gen.logprob)
14241447

14251448
# If requested save the k top logprobs
1426-
if gen.top_tokens is not None:
1449+
if args.top_logprobs > 0:
14271450
top_tokens.append(gen.top_tokens)
14281451

14291452
# Check if we should stop early

0 commit comments

Comments
 (0)