Skip to content

Commit cda3c3a

Browse files
AndreFCruzclaude
andcommitted
Code-review cleanups for gpt-5 WebAPI compat + CI fix
Review-driven simplifications on top of the gpt-5 PR (no behavior change): - Convert `_family_quirks` dict → `_WebAPIQuirks` frozen dataclass; drops 6 stringly-typed lookups and the defensive `.get(...)` on `numeric_percentage_mode`. - Consolidate 5 duplicate `isinstance(question, ChainOfThoughtQA)` sites in `_query_webapi_batch` into a single `is_cot` boolean; all non-CoT setup (params, unvalidated-reasoning warning, `reasoning_effort='none'`, system-prompt binding) now sits in one else branch. - Merge the two identical `api_call_params = dict(...)` builders (MCQ vs numeric); only `num_forward_passes` differs, ternary-computed. - Unify the two `dataclasses.replace(...)` calls in `_enable_numeric_percentage_mode` and tidy the log message. - Dict-comp for `valid_percent_tokens`; `float(ltp[tid])` in place of the `.item()` guard; f-string logging for consistency. CI fix: `pytest.importorskip('litellm')` in the three `test_*_init_*` tests that instantiate a real `WebAPILLMClassifier` — litellm is an optional `[apis]` extra and CI runs pytest without it, so those tests errored on `import litellm as real_litellm` instead of skipping. Matches the pattern in `test_plotting.py`. Full suite: 319 passed locally (unchanged from pre-PR baseline). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 68f190f commit cda3c3a

3 files changed

Lines changed: 113 additions & 140 deletions

File tree

folktexts/classifier/web_api_classifier.py

Lines changed: 91 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -46,28 +46,33 @@
4646
# request needs `max_tokens=4`, a 5-token numeric one needs `max_tokens=8`,
4747
# or the completion is truncated before any visible content is emitted.
4848
# Extend as further families exhibit similar caps.
49-
_OPENAI_MODEL_FAMILY_QUIRKS: dict[str, dict] = {
50-
"gpt-5": {
51-
"top_logprobs_max": 5,
52-
"max_tokens_overhead": 3,
53-
"force_reasoning_none": True,
54-
# Under `reasoning_effort='none'`, gpt-5 collapses the standard
55-
# `Answer (between 0 and 1): 0.` prefill to `'0.0'` regardless of
56-
# input (AUC ~chance on ACSIncome). Reframing as an integer
57-
# percentage (0-100, no prefill) unblocks the full digit range and
58-
# restores discrimination (probe → HIGH-income '92', LOW '12').
59-
"numeric_percentage_mode": True,
60-
},
61-
}
62-
_DEFAULT_FAMILY_QUIRKS: dict = {
63-
"top_logprobs_max": 20,
64-
"max_tokens_overhead": 0,
65-
"force_reasoning_none": False,
66-
"numeric_percentage_mode": False,
49+
@dataclasses.dataclass(frozen=True)
50+
class _WebAPIQuirks:
51+
"""Per-family value-level API constraints for the WebAPI backend."""
52+
top_logprobs_max: int = 20
53+
max_tokens_overhead: int = 0
54+
force_reasoning_none: bool = False
55+
# When True, `WebAPILLMClassifier.__init__` swaps a `DirectNumericQA`
56+
# for its `percentage=True` variant: gpt-5 under `reasoning_effort='none'`
57+
# collapses the standard `Answer (between 0 and 1): 0.` prefill to
58+
# `'0.0'` regardless of input (AUC ~chance on ACSIncome). Reframing as
59+
# an integer percentage (0-100, no prefill) unblocks the full digit
60+
# range and restores discrimination (probe → HIGH-income '92', LOW '12').
61+
numeric_percentage_mode: bool = False
62+
63+
64+
_OPENAI_MODEL_FAMILY_QUIRKS: dict[str, _WebAPIQuirks] = {
65+
"gpt-5": _WebAPIQuirks(
66+
top_logprobs_max=5,
67+
max_tokens_overhead=3,
68+
force_reasoning_none=True,
69+
numeric_percentage_mode=True,
70+
),
6771
}
72+
_DEFAULT_FAMILY_QUIRKS = _WebAPIQuirks()
6873

6974

70-
def _resolve_family_quirks(model_name: str) -> dict:
75+
def _resolve_family_quirks(model_name: str) -> _WebAPIQuirks:
7176
for family, quirks in _OPENAI_MODEL_FAMILY_QUIRKS.items():
7277
if family in model_name:
7378
return quirks
@@ -171,7 +176,7 @@ def __init__(
171176
# prompt config's question if it is a `DirectNumericQA` and the
172177
# user did not already set `percentage=True`.
173178
if (
174-
self._family_quirks.get("numeric_percentage_mode")
179+
self._family_quirks.numeric_percentage_mode
175180
and isinstance(self._prompt_config.suffix.question, DirectNumericQA)
176181
and not self._prompt_config.suffix.question.percentage
177182
):
@@ -209,42 +214,34 @@ def _enable_numeric_percentage_mode(self) -> None:
209214
new_q = dataclasses.replace(
210215
original_q, percentage=True, num_forward_passes=15
211216
)
212-
new_suffix = dataclasses.replace(self._prompt_config.suffix, question=new_q)
213-
214-
# Replace the numeric system prompt ONLY if the current one is the
215-
# QA subclass default (i.e. the caller didn't set --system-prompt).
217+
replace_kwargs = {
218+
"suffix": dataclasses.replace(self._prompt_config.suffix, question=new_q),
219+
}
220+
# Replace the numeric system prompt ONLY if the current one is the QA
221+
# subclass default (i.e. the caller didn't set --system-prompt): callers
222+
# who deliberately override it are never silently clobbered.
216223
current_sys = self._prompt_config.system_prompt
217224
is_default_sys = (
218225
current_sys is not None
219226
and current_sys.system_prompt == type(original_q).default_system_prompt
220227
)
221228
if is_default_sys:
222-
new_system_prompt = VarySystemPrompt(
229+
replace_kwargs["system_prompt"] = VarySystemPrompt(
223230
system_prompt=NUMERIC_PERCENTAGE_SYSTEM_PROMPT
224231
)
225-
self._prompt_config = dataclasses.replace(
226-
self._prompt_config,
227-
suffix=new_suffix,
228-
system_prompt=new_system_prompt,
229-
)
230-
else:
231-
self._prompt_config = dataclasses.replace(
232-
self._prompt_config, suffix=new_suffix
233-
)
232+
self._prompt_config = dataclasses.replace(self._prompt_config, **replace_kwargs)
234233
# `_encode_row` was built via `partial(...)` capturing the OLD
235234
# prompt_config; rebuild it against the swapped one.
236235
self._encode_row = partial(
237236
default_encode_row_prompt,
238237
task=self.task,
239238
prompt_config=self._prompt_config,
240239
)
240+
sys_note = "system prompt updated" if is_default_sys else "user system prompt preserved"
241241
logging.info(
242-
f"Enabled numeric-percentage mode for model '{self.model_name}': "
243-
f"prompt now asks for an integer percentage (0-100) instead of "
244-
f"the decimal `0.<digits>` prefill (workaround for "
245-
f"reasoning_effort='none' degeneration); "
246-
f"system prompt {'updated to' if is_default_sys else 'left as user override — '}"
247-
f"instruct answer format."
242+
f"Enabled numeric-percentage mode for '{self.model_name}': "
243+
f"prompt reframed as integer percentage (0-100) to work around "
244+
f"reasoning_effort='none' decimal-prefill degeneration; {sys_note}."
248245
)
249246

250247
@staticmethod
@@ -304,8 +301,10 @@ def _query_webapi_batch(
304301
responses_batch : list[dict]
305302
The returned JSON responses for each prompt in the batch.
306303
"""
307-
# Handle ChainOfThoughtQA with longer text generation
308-
if isinstance(question, ChainOfThoughtQA):
304+
is_cot = isinstance(question, ChainOfThoughtQA)
305+
306+
if is_cot:
307+
# CoT: free-form text generation, no logprobs.
309308
api_call_params = dict(
310309
temperature=self._resolve_temperature(question),
311310
max_tokens=question.max_new_tokens,
@@ -322,96 +321,77 @@ def _query_webapi_batch(
322321
"and provide your final probability estimate. Your response MUST end "
323322
"with 'Probability: X%' where X is a number between 0 and 100."
324323
)
325-
# Adapt number of forward passes for token-probability based methods.
326-
# Temperature is always 0 here: MC/numeric decode from the returned
327-
# top_logprobs (which OpenAI-style APIs report untempered), so sampling
328-
# would only add noise to the multi-pass token trajectory.
329-
elif question.num_forward_passes == 1:
330-
# Single token answers should require only one forward pass
331-
num_forward_passes = 1
332-
api_call_params = dict(
333-
temperature=0,
334-
max_tokens=num_forward_passes + self._family_quirks["max_tokens_overhead"],
335-
stream=False,
336-
seed=self.seed,
337-
logprobs=True,
338-
top_logprobs=self._family_quirks["top_logprobs_max"],
339-
)
340324
else:
341-
# NOTE: Models often generate "0." instead of directly outputting the fractional part
342-
# > Therefore: for multi-token answers, extra forward passes may be required
343-
# Add extra tokens for textual prefix, e.g., "The probability is: ..."
344-
num_forward_passes = question.num_forward_passes + 2
325+
# MCQ / DirectNumericQA: token-probability decoding.
326+
# Temperature is always 0: MC/numeric decode from the returned
327+
# top_logprobs (which OpenAI-style APIs report untempered), so
328+
# sampling would only add noise to the multi-pass trajectory.
329+
# For multi-token numeric answers, add 2 extra passes to cover
330+
# the "0." (or paraphrase) prefix the model may emit before digits.
331+
num_forward_passes = (
332+
1 if question.num_forward_passes == 1
333+
else question.num_forward_passes + 2
334+
)
345335
api_call_params = dict(
346336
temperature=0,
347-
max_tokens=num_forward_passes + self._family_quirks["max_tokens_overhead"],
337+
max_tokens=num_forward_passes + self._family_quirks.max_tokens_overhead,
348338
stream=False,
349339
seed=self.seed,
350340
logprobs=True,
351-
top_logprobs=self._family_quirks["top_logprobs_max"],
341+
top_logprobs=self._family_quirks.top_logprobs_max,
352342
)
353343

354-
# Warn once when MCQ/Numeric is run against a reasoning-capable model
355-
# whose WebAPI quirks haven't been explicitly validated. Reasoning
356-
# models tend to preamble/wrap the answer, exhausting the tight
357-
# `max_tokens` budget used by token-probability decoding — even with
358-
# `logprobs` support the numbers can silently collapse to chance.
359-
# Only fires for MCQ/Numeric (CoT is robust) and when the model was
360-
# NOT matched by an explicit entry in `_OPENAI_MODEL_FAMILY_QUIRKS`.
361-
if (
362-
not isinstance(question, ChainOfThoughtQA)
363-
and "reasoning_effort" in self.supported_params
364-
and self._family_quirks is _DEFAULT_FAMILY_QUIRKS
365-
and not self._warned_unvalidated_reasoning
366-
):
367-
self._warned_unvalidated_reasoning = True
368-
logging.warning(
369-
f"Model '{self.model_name}' advertises `reasoning_effort` "
370-
f"support (likely a reasoning/thinking model) but has no "
371-
f"validated entry in `_OPENAI_MODEL_FAMILY_QUIRKS`. "
372-
f"Multiple-choice / numeric prompting on reasoning models "
373-
f"can degrade to chance-level AUC (models preamble or wrap "
374-
f"the answer in markdown, exhausting the small `max_tokens` "
375-
f"budget). If results look wrong, use chain-of-thought "
376-
f"prompting (`--cot-prompting`) instead, or add an entry to "
377-
f"`_OPENAI_MODEL_FAMILY_QUIRKS` (see the gpt-5 entry for "
378-
f"reference: `numeric_percentage_mode`, `force_reasoning_none`, "
379-
f"`max_tokens_overhead`, `top_logprobs_max`)."
380-
)
344+
# Warn once when running against a reasoning-capable model whose
345+
# WebAPI quirks haven't been explicitly validated. Reasoning
346+
# models preamble/wrap the answer, exhausting the tight
347+
# `max_tokens` budget — token-probability decoding can silently
348+
# collapse to chance-level AUC. (CoT skips this branch entirely.)
349+
if (
350+
"reasoning_effort" in self.supported_params
351+
and self._family_quirks is _DEFAULT_FAMILY_QUIRKS
352+
and not self._warned_unvalidated_reasoning
353+
):
354+
self._warned_unvalidated_reasoning = True
355+
logging.warning(
356+
f"Model '{self.model_name}' advertises `reasoning_effort` "
357+
f"support (likely a reasoning/thinking model) but has no "
358+
f"validated entry in `_OPENAI_MODEL_FAMILY_QUIRKS`. "
359+
f"Multiple-choice / numeric prompting on reasoning models "
360+
f"can degrade to chance-level AUC (models preamble or wrap "
361+
f"the answer in markdown, exhausting the small `max_tokens` "
362+
f"budget). If results look wrong, use chain-of-thought "
363+
f"prompting (`--cot-prompting`) instead, or add an entry "
364+
f"to `_OPENAI_MODEL_FAMILY_QUIRKS` (see the gpt-5 entry "
365+
f"for reference)."
366+
)
381367

382-
# gpt-5.x refuses `logprobs` requests unless reasoning_effort='none'
383-
# is set — pass it only for MCQ/numeric (CoT reads free-form text and
384-
# benefits from the model's default reasoning budget).
385-
if (
386-
not isinstance(question, ChainOfThoughtQA)
387-
and self._family_quirks["force_reasoning_none"]
388-
):
389-
api_call_params["reasoning_effort"] = "none"
368+
# gpt-5.x refuses `logprobs` requests unless reasoning_effort='none'
369+
# is set. CoT keeps the model's default reasoning budget.
370+
if self._family_quirks.force_reasoning_none:
371+
api_call_params["reasoning_effort"] = "none"
372+
373+
# System prompt for MCQ/Numeric: use the one carried by
374+
# PromptConfig (the QA subclass default unless explicitly
375+
# cleared). `None` disables the system role entirely.
376+
system_prompt = (
377+
self.prompt_config.system_prompt()
378+
if self.prompt_config.system_prompt is not None
379+
else None
380+
)
381+
logging.debug(f"System prompt: {system_prompt}")
390382

391383
api_call_params = self._filter_supported_params(api_call_params)
392384

393385
# `logprobs` are load-bearing for token-probability decoding: dropping
394386
# them would only fail later, deep inside response decoding. Fail fast
395387
# instead (e.g. OpenAI o1/o3 don't support logprobs).
396-
if not isinstance(question, ChainOfThoughtQA) and "logprobs" not in api_call_params:
388+
if not is_cot and "logprobs" not in api_call_params:
397389
raise RuntimeError(
398390
f"Model '{self.model_name}' does not support `logprobs`, which "
399391
f"are required to decode multiple-choice/numeric risk estimates. "
400392
f"Use chain-of-thought prompting (--cot-prompting) instead."
401393
)
402394

403-
# Get system prompt depending on Q&A type (if not already set for ChainOfThoughtQA)
404-
if not isinstance(question, ChainOfThoughtQA):
405-
# Use the system prompt carried by PromptConfig (always the QA subclass
406-
# default unless the caller explicitly cleared it). `None` disables the
407-
# system role entirely. Bind unconditionally so it is always defined.
408-
system_prompt = (
409-
self.prompt_config.system_prompt()
410-
if self.prompt_config.system_prompt is not None
411-
else None
412-
)
413-
logging.debug(f"System prompt: {system_prompt}")
414-
415395
# Query model for each prompt in the batch
416396
responses_batch = []
417397
for prompt in prompts_batch:

folktexts/qa_interface.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -341,13 +341,11 @@ def _decode_percentage_expected_value(
341341
# Restrict to tokens that could be an integer percentage answer.
342342
# Rejects: multi-digit tokens with a `.`, values >100, non-digit
343343
# strings that slipped through _get_numeric_tokens.
344-
valid_percent_tokens: dict[str, int] = {}
345-
for tok, tid in numeric_tokens_vocab.items():
346-
if not re.fullmatch(r"\d{1,3}", tok):
347-
continue
348-
value = int(tok)
349-
if 0 <= value <= 100:
350-
valid_percent_tokens[tok] = tid
344+
valid_percent_tokens: dict[str, int] = {
345+
tok: tid
346+
for tok, tid in numeric_tokens_vocab.items()
347+
if re.fullmatch(r"\d{1,3}", tok) and 0 <= int(tok) <= 100
348+
}
351349
if not valid_percent_tokens:
352350
logging.warning(
353351
"No valid percentage tokens (integers 0-100) in the model's "
@@ -379,9 +377,7 @@ def _decode_percentage_expected_value(
379377

380378
probs_by_value: dict[int, float] = {}
381379
for tok, tid in valid_percent_tokens.items():
382-
p = ltp[tid]
383-
if not isinstance(p, float):
384-
p = p.item()
380+
p = float(ltp[tid])
385381
if p > 0:
386382
probs_by_value[int(tok)] = probs_by_value.get(int(tok), 0.0) + p
387383
total_mass = sum(probs_by_value.values())
@@ -394,9 +390,8 @@ def _decode_percentage_expected_value(
394390
return min(expected / 100.0, 1.0)
395391

396392
logging.warning(
397-
"No post-anchor digit position found in percentage-mode "
398-
"response (accumulated_text=%r); returning 0.0.",
399-
accumulated_text,
393+
f"No post-anchor digit position found in percentage-mode "
394+
f"response (accumulated_text={accumulated_text!r}); returning 0.0."
400395
)
401396
return 0.0
402397

tests/test_web_api_classifier.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
import pytest
1717

1818
from folktexts.classifier import WebAPILLMClassifier
19-
from folktexts.classifier.web_api_classifier import _DEFAULT_FAMILY_QUIRKS
19+
from folktexts.classifier.web_api_classifier import (
20+
_DEFAULT_FAMILY_QUIRKS,
21+
_WebAPIQuirks,
22+
)
2023
from folktexts.prompting import PromptConfig
2124
from folktexts.qa_interface import ChainOfThoughtQA
2225

@@ -272,18 +275,13 @@ def test_missing_logprobs_support_fails_fast_for_mcq(mcq_task):
272275

273276
# --- gpt-5 family quirks -----------------------------------------------------
274277

275-
_GPT5_QUIRKS = {
276-
"top_logprobs_max": 5,
277-
"max_tokens_overhead": 3,
278-
"force_reasoning_none": True,
279-
"numeric_percentage_mode": True,
280-
}
281-
_DEFAULT_QUIRKS = {
282-
"top_logprobs_max": 20,
283-
"max_tokens_overhead": 0,
284-
"force_reasoning_none": False,
285-
"numeric_percentage_mode": False,
286-
}
278+
_GPT5_QUIRKS = _WebAPIQuirks(
279+
top_logprobs_max=5,
280+
max_tokens_overhead=3,
281+
force_reasoning_none=True,
282+
numeric_percentage_mode=True,
283+
)
284+
_DEFAULT_QUIRKS = _WebAPIQuirks()
287285

288286

289287
@pytest.mark.parametrize("model_name, expected", [
@@ -769,7 +767,7 @@ def _fake_get_supported(**_kwargs):
769767
def _fake_completion(**_kwargs):
770768
raise RuntimeError("should not be called in __init__")
771769

772-
import litellm as real_litellm
770+
real_litellm = pytest.importorskip("litellm")
773771
monkeypatch.setattr(real_litellm, "success_callback", [], raising=False)
774772
monkeypatch.setattr(real_litellm, "completion", _fake_completion, raising=False)
775773
monkeypatch.setattr(
@@ -803,7 +801,7 @@ def _fake_get_supported(**_kwargs):
803801
def _fake_completion(**_kwargs):
804802
raise RuntimeError("should not be called in __init__")
805803

806-
import litellm as real_litellm
804+
real_litellm = pytest.importorskip("litellm")
807805
monkeypatch.setattr(real_litellm, "success_callback", [], raising=False)
808806
monkeypatch.setattr(real_litellm, "completion", _fake_completion, raising=False)
809807
monkeypatch.setattr(
@@ -838,7 +836,7 @@ def _fake_get_supported(**_kwargs):
838836
def _fake_completion(**_kwargs):
839837
raise RuntimeError("should not be called in __init__")
840838

841-
import litellm as real_litellm
839+
real_litellm = pytest.importorskip("litellm")
842840
monkeypatch.setattr(real_litellm, "success_callback", [], raising=False)
843841
monkeypatch.setattr(real_litellm, "completion", _fake_completion, raising=False)
844842
monkeypatch.setattr(

0 commit comments

Comments
 (0)