Skip to content

Commit c22dfbe

Browse files
committed
Surface new inference args
Signed-off-by: Zhengyuan Su <su.zhengyuan@u.nus.edu>
1 parent b0554f4 commit c22dfbe

5 files changed

Lines changed: 311 additions & 52 deletions

File tree

src/lumilake_server/common.py

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from dataclasses import asdict, dataclass
2-
from typing import Any
1+
from dataclasses import asdict, dataclass, fields
2+
from typing import Any, ClassVar
33

44

55
@dataclass(slots=True)
@@ -13,39 +13,118 @@ def to_dict(self) -> dict[str, str]:
1313

1414
@dataclass
1515
class GenerationConfig:
16+
"""LLM generation parameters. Add a typed field here and both the YAML
17+
parser allowlist and the runtime inference_spec pick it up automatically;
18+
use ``extra_sampling_params`` for vendor-specific keys not worth typing."""
19+
1620
model: str
1721
frequency_penalty: float | None = None
1822
logit_bias: dict[str, int] | None = None
1923
logprobs: int | None = None
2024
max_tokens: int | None = None
25+
min_tokens: int | None = None
2126
n: int | None = 1
2227
presence_penalty: float | None = None
28+
repetition_penalty: float | None = None
2329
seed: int | None = None
2430
stop: str | list[str] | None = None
2531
stream: bool | None = False
2632
stream_options: Any = None
2733
temperature: float | None = None
34+
top_k: int | None = None
2835
top_p: float | None = None
36+
min_p: float | None = None
2937
ignore_eos: bool = False
30-
# Forwarded to tokenizer.apply_chat_template (e.g. enable_thinking=False for Qwen3).
3138
chat_template_kwargs: dict[str, Any] | None = None
39+
extra_sampling_params: dict[str, Any] | None = None
40+
41+
# Engine-level
42+
max_model_len: int | None = None
43+
gpu_memory_utilization: float | None = None
44+
tensor_parallel_size: int | None = None
45+
dtype: str | None = None
46+
extra_engine_kwargs: dict[str, Any] | None = None
47+
48+
# Stripped by openai_kwargs() — OpenAI Chat API rejects these.
49+
_NON_OPENAI_FIELDS: ClassVar[tuple[str, ...]] = (
50+
"ignore_eos",
51+
"chat_template_kwargs",
52+
"repetition_penalty",
53+
"top_k",
54+
"min_p",
55+
"min_tokens",
56+
"extra_sampling_params",
57+
"max_model_len",
58+
"gpu_memory_utilization",
59+
"tensor_parallel_size",
60+
"dtype",
61+
"extra_engine_kwargs",
62+
)
63+
# Skipped by inference_spec() — not per-request sampler args.
64+
_NON_SAMPLER_FIELDS: ClassVar[frozenset[str]] = frozenset(
65+
{
66+
"model",
67+
"extra_sampling_params",
68+
"max_model_len",
69+
"gpu_memory_utilization",
70+
"tensor_parallel_size",
71+
"dtype",
72+
"extra_engine_kwargs",
73+
}
74+
)
75+
# Engine-level typed fields the runtime overlays onto the backend config.
76+
_ENGINE_OVERLAY_FIELDS: ClassVar[tuple[str, ...]] = (
77+
"max_model_len",
78+
"gpu_memory_utilization",
79+
"tensor_parallel_size",
80+
"dtype",
81+
)
3282

3383
def to_dict(self) -> dict[str, Any]:
3484
return asdict(self)
3585

3686
def openai_kwargs(self) -> dict[str, Any]:
3787
kwargs = self.to_dict()
38-
kwargs.pop("ignore_eos")
39-
kwargs.pop("chat_template_kwargs")
88+
for field_name in self._NON_OPENAI_FIELDS:
89+
kwargs.pop(field_name, None)
4090
return kwargs
4191

42-
@classmethod
43-
def from_env(cls, **kwargs) -> "GenerationConfig":
44-
# Construct a GenerationConfig from kwargs only. We intentionally do
45-
# not read LLM service credentials or defaults from environment here;
46-
# callers must pass explicit parameters (model is required).
47-
if "model" not in kwargs:
92+
def inference_spec(self) -> dict[str, Any]:
93+
"""Non-None typed samplers + extra_sampling_params. Raises on conflict."""
94+
spec: dict[str, Any] = {}
95+
for f in fields(self):
96+
if f.name in self._NON_SAMPLER_FIELDS:
97+
continue
98+
value = getattr(self, f.name)
99+
if value is not None:
100+
spec[f.name] = value
101+
extras = self.extra_sampling_params or {}
102+
conflicts = sorted(set(extras) & set(spec))
103+
if conflicts:
48104
raise ValueError(
49-
"GenerationConfig.from_env requires 'model' to be provided explicitly."
105+
f"extra_sampling_params conflict with typed fields: {conflicts}"
50106
)
107+
spec.update(extras)
108+
return spec
109+
110+
def engine_overlay(self) -> dict[str, Any]:
111+
"""Engine-level typed fields + extra_engine_kwargs. Raises on conflict."""
112+
overlay: dict[str, Any] = {
113+
name: getattr(self, name)
114+
for name in self._ENGINE_OVERLAY_FIELDS
115+
if getattr(self, name) is not None
116+
}
117+
extras = self.extra_engine_kwargs or {}
118+
conflicts = sorted(set(extras) & set(overlay))
119+
if conflicts:
120+
raise ValueError(
121+
f"extra_engine_kwargs conflict with typed fields: {conflicts}"
122+
)
123+
overlay.update(extras)
124+
return overlay
125+
126+
@classmethod
127+
def from_env(cls, **kwargs: Any) -> "GenerationConfig":
128+
if "model" not in kwargs:
129+
raise ValueError("GenerationConfig.from_env requires 'model'.")
51130
return cls(**kwargs)

src/lumilake_server/parser/yaml_parser.py

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,19 @@
8383
YAML equivalent of the n8n wire format exists in this parser.
8484
"""
8585

86-
from dataclasses import dataclass, field
86+
from dataclasses import dataclass, field, fields
8787
from typing import Any
8888

8989
import yaml
9090

91+
from lumilake_server.common import GenerationConfig
92+
9193
from .common import make_id as _make_id
9294

95+
_GENERATION_CONFIG_FIELDS: frozenset[str] = frozenset(
96+
f.name for f in fields(GenerationConfig)
97+
)
98+
9399
SUPPORTED_OPS: frozenset[str] = frozenset(
94100
{
95101
"DataOp",
@@ -966,27 +972,11 @@ def _emit_lambda_op(op_dict: dict[str, Any], entry: _OpEntry) -> None:
966972
def _build_generation_config(
967973
config: dict[str, Any], entry_id: str, op_kind: str = "LLMChatOp"
968974
) -> dict[str, Any]:
969-
# Mirror the subset that n8n parser produces; rest pass through.
970-
allowed = {
971-
"model",
972-
"frequency_penalty",
973-
"logit_bias",
974-
"logprobs",
975-
"max_tokens",
976-
"n",
977-
"presence_penalty",
978-
"seed",
979-
"stop",
980-
"stream",
981-
"stream_options",
982-
"temperature",
983-
"top_p",
984-
"ignore_eos",
985-
}
986-
unknown = set(config) - allowed
975+
unknown = set(config) - _GENERATION_CONFIG_FIELDS
987976
if unknown:
988977
raise ValueError(
989-
f"{op_kind} '{entry_id}' config has unknown fields: {sorted(unknown)}"
978+
f"{op_kind} '{entry_id}' config has unknown fields: {sorted(unknown)}. "
979+
"Use 'extra_sampling_params' for keys not on GenerationConfig."
990980
)
991981
return dict(config)
992982

src/lumilake_server/runtime/runtime_graph.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -514,18 +514,6 @@ def _mark_retrieval_upstream_nodes_visited(
514514
if isinstance(node, str) and node in graph_dict:
515515
visited_node_ids.add(node)
516516

517-
def _build_inference_spec_from_config(self, config: Any) -> dict[str, Any]:
518-
spec: dict[str, Any] = {}
519-
if config.max_tokens:
520-
spec["max_tokens"] = config.max_tokens
521-
if config.temperature is not None:
522-
spec["temperature"] = config.temperature
523-
if config.top_p is not None:
524-
spec["top_p"] = config.top_p
525-
if config.chat_template_kwargs:
526-
spec["chat_template_kwargs"] = config.chat_template_kwargs
527-
return spec
528-
529517
def _create_runtime_op(
530518
self,
531519
name: str,
@@ -566,19 +554,23 @@ def _build_model_spec(
566554
"revision": "main",
567555
}
568556
}
557+
overlay = config.engine_overlay()
569558
if backend == "vllm":
570-
spec["vllm"] = backend_config or self._build_default_vllm_backend_config()
559+
vllm_cfg = backend_config or self._build_default_vllm_backend_config()
560+
vllm_cfg.update(overlay)
561+
spec["vllm"] = vllm_cfg
571562
elif backend == "transformers":
572563
spec["transformers"] = backend_config or {
573564
"mode": "visual-embedding",
574565
"device_map": "auto",
575566
"trust_remote_code": True,
576567
}
577568
elif backend == "diffusers":
578-
spec["diffusers"] = backend_config or {
579-
"dtype": "bf16",
580-
"use_safetensors": True,
581-
}
569+
diffusers_cfg = backend_config or {"dtype": "bf16", "use_safetensors": True}
570+
# dtype is the only engine-overlay key meaningful for diffusers.
571+
if "dtype" in overlay:
572+
diffusers_cfg["dtype"] = overlay["dtype"]
573+
spec["diffusers"] = diffusers_cfg
582574
return spec
583575

584576
def _build_default_vllm_backend_config(
@@ -727,7 +719,7 @@ def _build_vlm_nodes_from_image_op(
727719
},
728720
)
729721

730-
inference_spec = self._build_inference_spec_from_config(llm_op.config)
722+
inference_spec = llm_op.config.inference_spec()
731723
backend_config = self._build_default_vllm_backend_config(enable_mm_embeds=True)
732724

733725
template_dependencies = self._collect_graph_template_dependencies(template_spec)
@@ -1164,7 +1156,7 @@ def _build_node_from_llm_op(
11641156
if isinstance(llm_op, ImageGenerationOp):
11651157
visited_node_ids.add(llm_op_id)
11661158
visited_node_ids.add(llm_op.content.id)
1167-
inference_spec = self._build_inference_spec_from_config(llm_op.config)
1159+
inference_spec = llm_op.config.inference_spec()
11681160
inference_spec.update(
11691161
{
11701162
"num_inference_steps": 8,
@@ -1231,7 +1223,7 @@ def _build_node_from_llm_op(
12311223
task_type = task_type_override or "inference"
12321224
backend = "vllm"
12331225

1234-
inference_spec = self._build_inference_spec_from_config(llm_op.config)
1226+
inference_spec = llm_op.config.inference_spec()
12351227
output_spec = None
12361228

12371229
if isinstance(llm_op, LLMChatOp) and llm_op.structural_outputs:

tests/runtime/test_runtime_graph_builder.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,3 +302,135 @@ def test_attach_lumid_cfg_not_injected_for_non_s3_list_inputs() -> None:
302302
ds = llm_node.data_spec
303303
assert "lumid_cfg" not in ds
304304
assert "s3_cfg" not in ds
305+
306+
307+
def test_runtime_graph_propagates_extended_sampler_fields() -> None:
308+
stock = input_placeholder("Stock")
309+
llm = LLMChatOp(
310+
[OpMessage(role="user", content=stock)],
311+
config=GenerationConfig(
312+
model="meta-llama/Llama-3.1-8B-Instruct",
313+
max_tokens=256,
314+
min_tokens=4,
315+
temperature=0.7,
316+
top_p=0.8,
317+
top_k=20,
318+
min_p=0.0,
319+
presence_penalty=1.5,
320+
frequency_penalty=0.5,
321+
repetition_penalty=1.0,
322+
seed=42,
323+
chat_template_kwargs={"enable_thinking": False},
324+
extra_sampling_params={"length_penalty": 1.1},
325+
),
326+
)
327+
output = as_output("result", llm)
328+
compiled = Graph.from_ops([output]).compile(Stock=["NVDA"])
329+
runtime_graph = RuntimeGraphBuilder().build(compiled)
330+
331+
spec = runtime_graph.nodes[llm.id].inference_spec
332+
assert spec["max_tokens"] == 256
333+
assert spec["min_tokens"] == 4
334+
assert spec["temperature"] == 0.7
335+
assert spec["top_p"] == 0.8
336+
assert spec["top_k"] == 20
337+
assert spec["min_p"] == 0.0
338+
assert spec["presence_penalty"] == 1.5
339+
assert spec["frequency_penalty"] == 0.5
340+
assert spec["repetition_penalty"] == 1.0
341+
assert spec["seed"] == 42
342+
assert spec["chat_template_kwargs"] == {"enable_thinking": False}
343+
# extra_sampling_params keys are merged into the spec as flat keys.
344+
assert spec["length_penalty"] == 1.1
345+
# model never appears in inference_spec — it's carried separately.
346+
assert "model" not in spec
347+
# The escape-hatch wrapper key itself is not leaked into the spec.
348+
assert "extra_sampling_params" not in spec
349+
350+
351+
def _build_llm_graph(**config_kwargs):
352+
stock = input_placeholder("Stock")
353+
cfg = GenerationConfig(model="meta-llama/Llama-3.1-8B-Instruct", **config_kwargs)
354+
llm = LLMChatOp([OpMessage(role="user", content=stock)], config=cfg)
355+
output = as_output("result", llm)
356+
compiled = Graph.from_ops([output]).compile(Stock=["NVDA"])
357+
return RuntimeGraphBuilder().build(compiled), llm.id
358+
359+
360+
_ENGINE_FIELDS = (
361+
"max_model_len",
362+
"gpu_memory_utilization",
363+
"tensor_parallel_size",
364+
"dtype",
365+
)
366+
367+
368+
def test_engine_overlay_lands_in_vllm_backend() -> None:
369+
runtime_graph, llm_id = _build_llm_graph(
370+
max_model_len=8192,
371+
gpu_memory_utilization=0.75,
372+
tensor_parallel_size=2,
373+
dtype="bf16",
374+
extra_engine_kwargs={"quantization": "fp8", "kv_cache_dtype": "fp8"},
375+
)
376+
node = runtime_graph.nodes[llm_id]
377+
vllm_cfg = node.model_spec["vllm"]
378+
assert vllm_cfg["max_model_len"] == 8192
379+
assert vllm_cfg["gpu_memory_utilization"] == 0.75
380+
assert vllm_cfg["tensor_parallel_size"] == 2
381+
assert vllm_cfg["dtype"] == "bf16"
382+
assert vllm_cfg["quantization"] == "fp8"
383+
assert vllm_cfg["kv_cache_dtype"] == "fp8"
384+
# Engine fields must NOT leak into inference_spec.
385+
for k in _ENGINE_FIELDS + ("quantization", "kv_cache_dtype"):
386+
assert k not in node.inference_spec
387+
388+
389+
def test_engine_overlay_omitted_when_unset() -> None:
390+
# gpu_memory_utilization has an env-var default in the baseline vLLM
391+
# config, so unset means "stays at env default" not "absent". The other
392+
# three fields have no default and should not appear when unset.
393+
runtime_graph, llm_id = _build_llm_graph()
394+
vllm_cfg = runtime_graph.nodes[llm_id].model_spec["vllm"]
395+
for k in ("max_model_len", "tensor_parallel_size", "dtype"):
396+
assert k not in vllm_cfg
397+
398+
399+
def test_engine_overlay_overrides_baseline_default() -> None:
400+
# gpu_memory_utilization is set in the baseline config; an explicit
401+
# value on GenerationConfig must override it.
402+
runtime_graph, llm_id = _build_llm_graph(gpu_memory_utilization=0.6)
403+
vllm_cfg = runtime_graph.nodes[llm_id].model_spec["vllm"]
404+
assert vllm_cfg["gpu_memory_utilization"] == 0.6
405+
406+
407+
def test_extra_sampling_params_conflict_rejected() -> None:
408+
stock = input_placeholder("Stock")
409+
llm = LLMChatOp(
410+
[OpMessage(role="user", content=stock)],
411+
config=GenerationConfig(
412+
model="meta-llama/Llama-3.1-8B-Instruct",
413+
temperature=0.7,
414+
extra_sampling_params={"temperature": 0.0},
415+
),
416+
)
417+
output = as_output("result", llm)
418+
compiled = Graph.from_ops([output]).compile(Stock=["NVDA"])
419+
with pytest.raises(ValueError, match="extra_sampling_params conflict"):
420+
RuntimeGraphBuilder().build(compiled)
421+
422+
423+
def test_extra_engine_kwargs_conflict_rejected() -> None:
424+
stock = input_placeholder("Stock")
425+
llm = LLMChatOp(
426+
[OpMessage(role="user", content=stock)],
427+
config=GenerationConfig(
428+
model="meta-llama/Llama-3.1-8B-Instruct",
429+
max_model_len=8192,
430+
extra_engine_kwargs={"max_model_len": 4096},
431+
),
432+
)
433+
output = as_output("result", llm)
434+
compiled = Graph.from_ops([output]).compile(Stock=["NVDA"])
435+
with pytest.raises(ValueError, match="extra_engine_kwargs conflict"):
436+
RuntimeGraphBuilder().build(compiled)

0 commit comments

Comments
 (0)