Skip to content

Commit 6f2668d

Browse files
review updates
Signed-off-by: AngeloDanducci <angelo.danducci.ii@ibm.com>
1 parent 94a3d61 commit 6f2668d

9 files changed

Lines changed: 400 additions & 56 deletions

File tree

mellea/backends/backend.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@ def __init__(
4747
self.model_id = model_id
4848
self.model_options = model_options if model_options is not None else {}
4949
self.formatter: ChatFormatter = formatter
50+
self._warned_about: set[str] = set()

mellea/backends/huggingface.py

Lines changed: 134 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -651,13 +651,48 @@ async def _generate_from_intrinsic(
651651
user_params["tokenizer"] = self._tokenizer
652652
generate_input.update(user_params)
653653

654+
want_scores = bool(
655+
model_options.get(ModelOption.LOGITS)
656+
or model_options.get(ModelOption.RAW_LOGITS)
657+
)
658+
if model_options.get(ModelOption.LOGITS):
659+
generate_input["output_scores"] = True
660+
if model_options.get(ModelOption.RAW_LOGITS):
661+
generate_input["output_logits"] = True
662+
663+
# When logits are requested, intercept the raw GenerateDecoderOnlyOutput that
664+
# generate_with_transformers produces internally but never returns (it wraps
665+
# everything into a ChatCompletionResponse). We proxy self._model so that
666+
# .generate() stores the raw output in raw_hf_output_cell before returning it;
667+
# granite_formatters_processing then writes it to mot._meta["hf_output"] for
668+
# _surface_logits in post_processing.
669+
raw_hf_output_cell: list[GenerateDecoderOnlyOutput | None] = [None]
670+
671+
model_arg = self._model
672+
if want_scores:
673+
_real_model = self._model
674+
675+
class _CapturingModelProxy:
676+
def generate(self_proxy, *args: Any, **kwargs: Any) -> Any:
677+
result = cast(Callable[..., Any], _real_model.generate)(
678+
*args, **kwargs
679+
)
680+
if isinstance(result, GenerateDecoderOnlyOutput):
681+
raw_hf_output_cell[0] = result
682+
return result
683+
684+
def __getattr__(self_proxy, name: str) -> Any:
685+
return getattr(_real_model, name)
686+
687+
model_arg = _CapturingModelProxy() # type: ignore[assignment]
688+
654689
chat_response = asyncio.to_thread(
655690
self._generate_with_adapter_lock,
656691
adapter.qualified_name,
657692
granite_formatters.base.util.generate_with_transformers, # type: ignore
658693
# Passed as args/kwargs to generate.
659694
self._tokenizer,
660-
self._model,
695+
model_arg,
661696
generate_input,
662697
other_input,
663698
)
@@ -681,8 +716,18 @@ async def granite_formatters_processing(
681716
except json.JSONDecodeError as e:
682717
raise Exception(f"Intrinsic did not return a JSON: {chunk}") from e
683718

684-
# TODO: If we want to support caches, we need to get the GenerateDecoderOnlyOutput. This means we
685-
# probably need to break out the pieces from `generate_with_transformers`.
719+
# If logits were requested, stash the intercepted raw output so that
720+
# post_processing/_surface_logits can populate generation.logits/raw_logits.
721+
if want_scores:
722+
if raw_hf_output_cell[0] is not None:
723+
mot._meta["hf_output"] = raw_hf_output_cell[0]
724+
else:
725+
MelleaLogger.get_logger().warning(
726+
"ModelOption.LOGITS/RAW_LOGITS requested on intrinsic path but "
727+
"generate_with_transformers did not return a GenerateDecoderOnlyOutput; "
728+
"generation.logits and generation.raw_logits will be None."
729+
)
730+
686731
# processing expects a str or a GenerateDecoderOnlyOutput. Extract the str.
687732
return await self.processing(
688733
mot, res.choices[0].message.content, input_ids=input_ids
@@ -941,6 +986,12 @@ async def _generate_from_context_with_kv_cache(
941986
# transformers' generate() requires a tokenizer to decode stop_strings.
942987
generate_kwargs["tokenizer"] = self._tokenizer
943988

989+
kv_scores_kwargs: dict[str, Any] = {}
990+
if model_options.get(ModelOption.LOGITS):
991+
kv_scores_kwargs["output_scores"] = True
992+
if model_options.get(ModelOption.RAW_LOGITS):
993+
kv_scores_kwargs["output_logits"] = True
994+
944995
chat_response = asyncio.to_thread(
945996
self._generate_with_adapter_lock,
946997
"", # Empty for no adapters.
@@ -951,7 +1002,7 @@ async def _generate_from_context_with_kv_cache(
9511002
past_key_values=merged_cache,
9521003
attention_mask=attention_mask.to(self._device),
9531004
return_dict_in_generate=True,
954-
output_scores=True,
1005+
**kv_scores_kwargs,
9551006
**generate_kwargs,
9561007
**streaming_kwargs, # type: ignore
9571008
**format_kwargs, # type: ignore
@@ -1112,6 +1163,12 @@ async def _generate_from_context_standard(
11121163
# transformers' generate() requires a tokenizer to decode stop_strings.
11131164
generate_kwargs["tokenizer"] = self._tokenizer
11141165

1166+
scores_kwargs: dict[str, Any] = {}
1167+
if model_options.get(ModelOption.LOGITS):
1168+
scores_kwargs["output_scores"] = True
1169+
if model_options.get(ModelOption.RAW_LOGITS):
1170+
scores_kwargs["output_logits"] = True
1171+
11151172
chat_response = asyncio.to_thread(
11161173
self._generate_with_adapter_lock,
11171174
"", # Empty for no adapters.
@@ -1121,6 +1178,7 @@ async def _generate_from_context_standard(
11211178
attention_mask=input_ids["attention_mask"],
11221179
return_dict_in_generate=True,
11231180
use_cache=self._use_caches, # Only create KV cache if caching is enabled
1181+
**scores_kwargs,
11241182
**generate_kwargs,
11251183
**streaming_kwargs, # type: ignore
11261184
**format_kwargs, # type: ignore
@@ -1220,6 +1278,50 @@ async def processing(
12201278
),
12211279
)
12221280

1281+
def _surface_logits(
1282+
self, mot: ModelOutputThunk, hf_output: GenerateDecoderOnlyOutput
1283+
) -> None:
1284+
"""Populate mot.generation.logits and/or raw_logits from hf_output when requested.
1285+
1286+
Checks ModelOption.LOGITS (processed scores) and ModelOption.RAW_LOGITS (raw
1287+
LM-head logits) in mot._model_options. Skips population when
1288+
num_return_sequences > 1, logging a warning the first time per backend instance.
1289+
1290+
Args:
1291+
mot: The output thunk whose generation metadata will be updated.
1292+
hf_output: The HuggingFace generation output containing scores/logits.
1293+
"""
1294+
hf_field = {ModelOption.LOGITS: "scores", ModelOption.RAW_LOGITS: "logits"}
1295+
for opt, tensors, attr in (
1296+
(ModelOption.LOGITS, hf_output.scores, "logits"),
1297+
(ModelOption.RAW_LOGITS, hf_output.logits, "raw_logits"),
1298+
):
1299+
if not (mot._model_options and mot._model_options.get(opt)):
1300+
continue
1301+
if tensors is None:
1302+
MelleaLogger.get_logger().debug(
1303+
"%s requested but hf_output.%s is None; generation.%s will not be populated.",
1304+
opt,
1305+
hf_field[opt],
1306+
attr,
1307+
)
1308+
continue
1309+
warn_key = f"nrs_{opt}"
1310+
if tensors[0].shape[0] > 1:
1311+
if warn_key not in self._warned_about:
1312+
self._warned_about.add(warn_key)
1313+
MelleaLogger.get_logger().warning(
1314+
"%s is set but num_return_sequences > 1; "
1315+
"logit tensors are ambiguous across sequences and will not be populated.",
1316+
opt,
1317+
)
1318+
else:
1319+
setattr(
1320+
mot.generation,
1321+
attr,
1322+
tuple(s.squeeze(0).detach().clone() for s in tensors),
1323+
)
1324+
12231325
async def post_processing(
12241326
self,
12251327
mot: ModelOutputThunk,
@@ -1262,7 +1364,11 @@ class used during generation, if any.
12621364
if (
12631365
self._use_caches
12641366
and isinstance(hf_output, GenerateDecoderOnlyOutput)
1265-
and (hf_output.past_key_values is not None or hf_output.scores is not None)
1367+
and (
1368+
hf_output.past_key_values is not None
1369+
or hf_output.scores is not None
1370+
or hf_output.logits is not None
1371+
)
12661372
):
12671373
output_complete = hf_output.sequences[0]
12681374
kv_cache: DynamicCache | None = hf_output.past_key_values # type: ignore
@@ -1282,29 +1388,19 @@ class used during generation, if any.
12821388
cache_key = id(mot.value)
12831389
self.cache_put(cache_key, cache_info)
12841390

1285-
# Surface logits to caller before clearing — scores move to LRU cache;
1286-
# when LOGITS=True the tuple views also keep them alive via generation.logits.
1287-
if (
1288-
hf_output.scores is not None
1289-
and mot._model_options
1290-
and mot._model_options.get(ModelOption.LOGITS)
1291-
):
1292-
# squeeze(0): hf_output.scores is (1, vocab_size) per token; normalise to (vocab_size,)
1293-
mot.generation.logits = tuple(s.squeeze(0) for s in hf_output.scores)
1391+
# Surface logits before clearing — scores move to LRU cache; when
1392+
# LOGITS/RAW_LOGITS=True the tuple views also keep them alive via generation.
1393+
if mot._model_options:
1394+
self._surface_logits(mot, hf_output)
12941395

1295-
# Clear KV cache and scores from HF output - they're retained via the LRU cache
1296-
# (and, when LOGITS=True, also via the views in mot.generation.logits).
1396+
# Clear KV cache and scores from HF output; retained via LRU cache above.
12971397
hf_output.past_key_values = None
12981398
hf_output.scores = None
1299-
elif (
1300-
isinstance(hf_output, GenerateDecoderOnlyOutput)
1301-
and hf_output.scores is not None
1302-
and mot._model_options
1303-
and mot._model_options.get(ModelOption.LOGITS)
1304-
):
1305-
# Caching disabled — scores were not moved to LRU, surface them directly
1306-
# squeeze(0): normalise (1, vocab_size) per token to (vocab_size,)
1307-
mot.generation.logits = tuple(s.squeeze(0) for s in hf_output.scores)
1399+
hf_output.logits = None
1400+
elif isinstance(hf_output, GenerateDecoderOnlyOutput) and mot._model_options:
1401+
# Caching disabled — surface scores/raw logits directly if requested.
1402+
self._surface_logits(mot, hf_output)
1403+
hf_output.logits = None
13081404

13091405
# Only scan for tools if we are not doing structured output and tool calls were provided to the model.
13101406
if _format is None and tool_calls:
@@ -1526,6 +1622,12 @@ async def generate_from_raw(
15261622
# transformers' generate() requires a tokenizer to decode stop_strings.
15271623
generate_kwargs["tokenizer"] = self._tokenizer
15281624

1625+
raw_scores_kwargs: dict[str, Any] = {}
1626+
if model_opts.get(ModelOption.LOGITS):
1627+
raw_scores_kwargs["output_scores"] = True
1628+
if model_opts.get(ModelOption.RAW_LOGITS):
1629+
raw_scores_kwargs["output_logits"] = True
1630+
15291631
_start = time.perf_counter()
15301632
try:
15311633
outputs = await asyncio.to_thread(
@@ -1536,7 +1638,7 @@ async def generate_from_raw(
15361638
input_ids=inputs["input_ids"],
15371639
attention_mask=inputs["attention_mask"],
15381640
return_dict_in_generate=True,
1539-
output_scores=True,
1641+
**raw_scores_kwargs,
15401642
**generate_kwargs,
15411643
**format_kwargs,
15421644
)
@@ -1571,6 +1673,7 @@ async def generate_from_raw(
15711673
agg_prompt = 0
15721674
agg_completion = 0
15731675
want_logits = bool(model_opts and model_opts.get(ModelOption.LOGITS))
1676+
want_raw_logits = bool(model_opts and model_opts.get(ModelOption.RAW_LOGITS))
15741677
for i, decoded_result in enumerate(decoded_results):
15751678
n_prompt_tokens = inputs["input_ids"][i].size(0) # type: ignore
15761679
n_completion_tokens = len(sequences_to_decode[i])
@@ -1593,6 +1696,11 @@ async def generate_from_raw(
15931696
step_scores[i].detach().clone() for step_scores in outputs.scores
15941697
)
15951698

1699+
if want_raw_logits and outputs.logits is not None:
1700+
result.generation.raw_logits = tuple(
1701+
step_logits[i].detach().clone() for step_logits in outputs.logits
1702+
)
1703+
15961704
action = actions[i]
15971705
result.parsed_repr = (
15981706
action.parse(result) if isinstance(action, Component) else result.value

mellea/backends/litellm.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,10 +251,15 @@ def _make_backend_specific_and_remove(
251251
# OpenAI compatible endpoints should accept both (and Watsonx does accept both).
252252
model_opts_remapping[ModelOption.MAX_NEW_TOKENS] = "max_tokens"
253253

254-
if model_options.get(ModelOption.LOGITS):
255-
MelleaLogger.get_logger().warning(
256-
"ModelOption.LOGITS is not supported by the LiteLLM backend; generation.logits will be None."
257-
)
254+
for opt, field in (
255+
(ModelOption.LOGITS, "generation.logits"),
256+
(ModelOption.RAW_LOGITS, "generation.raw_logits"),
257+
):
258+
if model_options.get(opt) and opt not in self._warned_about:
259+
self._warned_about.add(opt)
260+
MelleaLogger.get_logger().warning(
261+
f"{opt!r} is not supported by the LiteLLM backend; {field} will be None."
262+
)
258263

259264
backend_specific = ModelOption.replace_keys(model_options, model_opts_remapping)
260265
backend_specific = ModelOption.remove_special_keys(backend_specific)

mellea/backends/model_options.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ class ModelOption:
3232
STREAM (str): Sentinel key for enabling streaming responses.
3333
STOP_SEQUENCES (str): Sentinel key for a `list[str]` of strings that, when
3434
encountered in the model output, cause generation to halt.
35-
LOGITS (str): Sentinel key for requesting per-token logit scores from the backend.
35+
LOGITS (str): Sentinel key for requesting per-token processed logit scores (post-LogitsProcessor).
36+
RAW_LOGITS (str): Sentinel key for requesting per-token raw LM-head logits (pre-LogitsProcessor).
3637
"""
3738

3839
TOOLS = "@@@tools@@@"
@@ -71,19 +72,38 @@ class ModelOption:
7172
text-generation endpoint).
7273
"""
7374
LOGITS = "@@@logits@@@"
74-
"""When `True`, request that the backend return per-token logit scores.
75+
"""When `True`, request per-token processed logit scores (``output_scores``).
7576
76-
Scores are exposed on `mot.generation.logits` as a tuple of 1-D
77-
tensors of shape `(vocab_size,)`, one per generated token. This shape
78-
is consistent across both the standard and batch (`generate_from_raw`)
79-
HuggingFace paths.
77+
These are logits *after* the ``LogitsProcessor`` chain has run —
78+
temperature scaling, top-k/top-p masking, repetition penalty, etc.
79+
Exposed on ``mot.generation.logits`` as a tuple of 1-D tensors of shape
80+
``(vocab_size,)``, one per generated token.
8081
8182
Only supported by the HuggingFace local backend. Backends that cannot
8283
return logits (OpenAI, Ollama, LiteLLM, WatsonX) log a warning when this
83-
option is set and leave `generation.logits` as `None`.
84+
option is set and leave ``generation.logits`` as ``None``.
8485
85-
**Streaming not supported**: when `ModelOption.STREAM=True`, logit
86-
scores are not available and `mot.generation.logits` will be `None`.
86+
**Streaming not supported**: when ``ModelOption.STREAM=True``, logit
87+
scores are not available and ``mot.generation.logits`` will be ``None``.
88+
89+
See also ``ModelOption.RAW_LOGITS`` for unprocessed LM-head output.
90+
"""
91+
92+
RAW_LOGITS = "@@@raw_logits@@@"
93+
"""When `True`, request per-token raw logits (``output_logits``).
94+
95+
These are the raw, unprocessed logits straight from the LM head —
96+
the model's actual output *before* any ``LogitsProcessor`` transforms
97+
(temperature, top-k/top-p, repetition penalty, etc.).
98+
Exposed on ``mot.generation.raw_logits`` as a tuple of 1-D tensors of
99+
shape ``(vocab_size,)``, one per generated token.
100+
101+
Only supported by the HuggingFace local backend.
102+
103+
**Streaming not supported**: when ``ModelOption.STREAM=True``, raw
104+
logits are not available and ``mot.generation.raw_logits`` will be ``None``.
105+
106+
See also ``ModelOption.LOGITS`` for processor-transformed scores.
87107
"""
88108

89109
@staticmethod

mellea/backends/ollama.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,15 @@ def _make_backend_specific_and_remove(
286286
Returns:
287287
a new dict
288288
"""
289-
if model_options.get(ModelOption.LOGITS):
290-
MelleaLogger.get_logger().warning(
291-
"ModelOption.LOGITS is not supported by the Ollama backend; generation.logits will be None."
292-
)
289+
for opt, field in (
290+
(ModelOption.LOGITS, "generation.logits"),
291+
(ModelOption.RAW_LOGITS, "generation.raw_logits"),
292+
):
293+
if model_options.get(opt) and opt not in self._warned_about:
294+
self._warned_about.add(opt)
295+
MelleaLogger.get_logger().warning(
296+
f"{opt!r} is not supported by the Ollama backend; {field} will be None."
297+
)
293298

294299
backend_specific = ModelOption.replace_keys(
295300
model_options, self.from_mellea_model_opts_map

mellea/backends/openai.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -425,10 +425,15 @@ def _make_backend_specific_and_remove(
425425

426426
backend_specific = ModelOption.replace_keys(model_options, remap_dict)
427427

428-
if model_options.get(ModelOption.LOGITS):
429-
MelleaLogger.get_logger().warning(
430-
"ModelOption.LOGITS is not supported by the OpenAI backend; generation.logits will be None."
431-
)
428+
for opt, field in (
429+
(ModelOption.LOGITS, "generation.logits"),
430+
(ModelOption.RAW_LOGITS, "generation.raw_logits"),
431+
):
432+
if model_options.get(opt) and opt not in self._warned_about:
433+
self._warned_about.add(opt)
434+
MelleaLogger.get_logger().warning(
435+
f"{opt!r} is not supported by the OpenAI backend; {field} will be None."
436+
)
432437

433438
# OpenAI Backend has specific filtering functionality.
434439
if is_chat_context:

0 commit comments

Comments
 (0)