Skip to content

Commit 159d340

Browse files
authored
Merge pull request #311 from SuperMarioYL/fix/response-validation-remaining-approaches
Guard against empty/None/truncated provider responses in rto, self_consistency, reread, leap
2 parents df018d6 + 822222c commit 159d340

12 files changed

Lines changed: 201 additions & 20 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ jobs:
3636
pip install -r tests/requirements.txt
3737
pip install -e .
3838
39+
- name: Cache HuggingFace models
40+
uses: actions/cache@v3
41+
with:
42+
path: ~/.cache/huggingface
43+
key: ${{ runner.os }}-hf-codelion-dhara-250m
44+
restore-keys: |
45+
${{ runner.os }}-hf-
46+
3947
- name: Run unit tests (no server required)
4048
run: |
4149
# Set up local inference environment
@@ -81,10 +89,18 @@ jobs:
8189
pip install -r tests/requirements.txt
8290
pip install -e .
8391
92+
- name: Cache HuggingFace models
93+
uses: actions/cache@v3
94+
with:
95+
path: ~/.cache/huggingface
96+
key: ${{ runner.os }}-hf-codelion-dhara-250m
97+
restore-keys: |
98+
${{ runner.os }}-hf-
99+
84100
- name: Start optillm server
85101
run: |
86102
echo "Starting optillm server for integration tests..."
87-
OPTILLM_API_KEY=optillm python optillm.py --model Qwen/Qwen2.5-Coder-0.5B-Instruct --port 8000 &
103+
OPTILLM_API_KEY=optillm python optillm.py --model codelion/dhara-250m --port 8000 &
88104
echo $! > server.pid
89105
90106
# Wait for server to be ready
@@ -174,12 +190,20 @@ jobs:
174190
pip install -r tests/requirements.txt
175191
pip install -e .
176192
193+
- name: Cache HuggingFace models
194+
uses: actions/cache@v3
195+
with:
196+
path: ~/.cache/huggingface
197+
key: ${{ runner.os }}-hf-codelion-dhara-250m
198+
restore-keys: |
199+
${{ runner.os }}-hf-
200+
177201
- name: Start optillm server with conversation logging
178202
run: |
179203
echo "Starting optillm server with conversation logging..."
180204
mkdir -p /tmp/optillm_conversations
181205
OPTILLM_API_KEY=optillm python optillm.py \
182-
--model Qwen/Qwen2.5-Coder-0.5B-Instruct \
206+
--model codelion/dhara-250m \
183207
--port 8000 \
184208
--log-conversations \
185209
--conversation-log-dir /tmp/optillm_conversations &

‎optillm/__init__.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Version information
2-
__version__ = "0.3.15"
2+
__version__ = "0.3.16"
3+
4+
import os as _os
5+
6+
# An empty Hugging Face token is not the same as "no token". huggingface_hub 1.x
7+
# (pulled in by transformers 5) sends an empty token as the literal header
8+
# "Authorization: Bearer ", which the HTTP stack rejects with
9+
# "Illegal header value b'Bearer '". This happens whenever HF_TOKEN is present but
10+
# blank, e.g. a forked-PR CI run where ${{ secrets.HF_TOKEN }} resolves to "".
11+
# Treat any blank HF token env var as unset so anonymous access is used instead.
12+
for _hf_token_var in ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_HUB_TOKEN"):
13+
if _hf_token_var in _os.environ and not _os.environ[_hf_token_var].strip():
14+
del _os.environ[_hf_token_var]
315

416
# Import from server module
517
from .server import (

‎optillm/leap.py‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ def extract_output(self, text: str) -> str:
2828
match = re.search(r'<output>(.*?)(?:</output>|$)', text, re.DOTALL)
2929
return match.group(1).strip() if match else ""
3030

31+
def _extract_content(self, response, context: str) -> str:
32+
"""Validate a provider response and return its message content.
33+
34+
Guards against the empty / None / length-truncated responses that would
35+
otherwise raise IndexError/TypeError downstream (e.g. when the content
36+
is fed to extract_output or split). Mirrors the response-validation
37+
idiom already used in moa/bon/plansearch.
38+
"""
39+
if (response is None or
40+
not response.choices or
41+
response.choices[0].message.content is None or
42+
response.choices[0].finish_reason == "length"):
43+
raise Exception(f"LEAP: provider returned an empty, None, or truncated response while {context}")
44+
return response.choices[0].message.content
45+
3146
def extract_examples_from_query(self, initial_query: str) -> List[Tuple[str, str]]:
3247
logger.info("Extracting examples from initial query")
3348

@@ -66,7 +81,7 @@ def extract_examples_from_query(self, initial_query: str) -> List[Tuple[str, str
6681
optillm.conversation_logger.log_provider_call(self.request_id, provider_request, response_dict)
6782

6883
self.leap_completion_tokens += response.usage.completion_tokens
69-
examples_str = self.extract_output(response.choices[0].message.content)
84+
examples_str = self.extract_output(self._extract_content(response, "extracting examples from the query"))
7085
logger.debug(f"Extracted examples: {examples_str}")
7186
examples = []
7287
if examples_str:
@@ -109,7 +124,7 @@ def generate_mistakes(self, examples: List[Tuple[str, str]]) -> List[Tuple[str,
109124
response_dict = response.model_dump() if hasattr(response, 'model_dump') else response
110125
optillm.conversation_logger.log_provider_call(self.request_id, provider_request, response_dict)
111126
self.leap_completion_tokens += response.usage.completion_tokens
112-
generated_reasoning = response.choices[0].message.content
127+
generated_reasoning = self._extract_content(response, "generating reasoning for a mistake example")
113128
generated_answer = self.extract_output(generated_reasoning)
114129
if generated_answer != correct_answer:
115130
mistakes.append((question, generated_reasoning, generated_answer, correct_answer))
@@ -148,7 +163,7 @@ def generate_low_level_principles(self, mistakes: List[Tuple[str, str, str, str]
148163
response_dict = response.model_dump() if hasattr(response, 'model_dump') else response
149164
optillm.conversation_logger.log_provider_call(self.request_id, provider_request, response_dict)
150165
self.leap_completion_tokens += response.usage.completion_tokens
151-
self.low_level_principles.append(self.extract_output(response.choices[0].message.content))
166+
self.low_level_principles.append(self.extract_output(self._extract_content(response, "generating low-level principles")))
152167
return self.low_level_principles
153168

154169
def generate_high_level_principles(self) -> List[str]:
@@ -181,7 +196,7 @@ def generate_high_level_principles(self) -> List[str]:
181196
response_dict = response.model_dump() if hasattr(response, 'model_dump') else response
182197
optillm.conversation_logger.log_provider_call(self.request_id, provider_request, response_dict)
183198
self.leap_completion_tokens += response.usage.completion_tokens
184-
self.high_level_principles = self.extract_output(response.choices[0].message.content).split("\n")
199+
self.high_level_principles = self.extract_output(self._extract_content(response, "generating high-level principles")).split("\n")
185200
return self.high_level_principles
186201

187202
def apply_principles(self, query: str) -> str:
@@ -210,7 +225,7 @@ def apply_principles(self, query: str) -> str:
210225
response_dict = response.model_dump() if hasattr(response, 'model_dump') else response
211226
optillm.conversation_logger.log_provider_call(self.request_id, provider_request, response_dict)
212227
self.leap_completion_tokens += response.usage.completion_tokens
213-
return response.choices[0].message.content
228+
return self._extract_content(response, "applying principles to the query")
214229

215230
def solve(self, initial_query: str) -> str:
216231
logger.info("Starting LEAP process")

‎optillm/reread.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,20 @@ def re2_approach(system_prompt, initial_query, client, model, n=1, request_confi
5050
response_dict = response.model_dump() if hasattr(response, 'model_dump') else response
5151
optillm.conversation_logger.log_provider_call(request_id, provider_request, response_dict)
5252

53+
if response is None or not response.choices:
54+
raise Exception("re2_approach: provider returned an empty or None response")
5355
re2_completion_tokens += response.usage.completion_tokens
5456
if n == 1:
57+
if (response.choices[0].message.content is None or
58+
response.choices[0].finish_reason == "length"):
59+
raise Exception("re2_approach: provider returned a None or truncated response")
5560
return response.choices[0].message.content.strip(), re2_completion_tokens
5661
else:
57-
return [choice.message.content.strip() for choice in response.choices], re2_completion_tokens
62+
# Keep only choices that produced usable, non-truncated content,
63+
# consistent with how the single-choice path treats a truncation.
64+
return [choice.message.content.strip() for choice in response.choices
65+
if choice.message.content is not None
66+
and choice.finish_reason != "length"], re2_completion_tokens
5867

5968
except Exception as e:
6069
logger.error(f"Error in RE2 approach: {str(e)}")

‎optillm/rto.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ def round_trip_optimization(system_prompt: str, initial_query: str, client, mode
4141
response_dict = response_c1.model_dump() if hasattr(response_c1, 'model_dump') else response_c1
4242
optillm.conversation_logger.log_provider_call(request_id, provider_request, response_dict)
4343

44+
if (response_c1 is None or
45+
not response_c1.choices or
46+
response_c1.choices[0].message.content is None or
47+
response_c1.choices[0].finish_reason == "length"):
48+
raise Exception("RTO: provider returned an empty, None, or truncated response for the initial code (C1)")
4449
c1 = response_c1.choices[0].message.content
4550
rto_completion_tokens += response_c1.usage.completion_tokens
4651

@@ -61,6 +66,11 @@ def round_trip_optimization(system_prompt: str, initial_query: str, client, mode
6166
response_dict = response_q2.model_dump() if hasattr(response_q2, 'model_dump') else response_q2
6267
optillm.conversation_logger.log_provider_call(request_id, provider_request, response_dict)
6368

69+
if (response_q2 is None or
70+
not response_q2.choices or
71+
response_q2.choices[0].message.content is None or
72+
response_q2.choices[0].finish_reason == "length"):
73+
raise Exception("RTO: provider returned an empty, None, or truncated response for the description (Q2)")
6474
q2 = response_q2.choices[0].message.content
6575
rto_completion_tokens += response_q2.usage.completion_tokens
6676

@@ -81,6 +91,11 @@ def round_trip_optimization(system_prompt: str, initial_query: str, client, mode
8191
response_dict = response_c2.model_dump() if hasattr(response_c2, 'model_dump') else response_c2
8292
optillm.conversation_logger.log_provider_call(request_id, provider_request, response_dict)
8393

94+
if (response_c2 is None or
95+
not response_c2.choices or
96+
response_c2.choices[0].message.content is None or
97+
response_c2.choices[0].finish_reason == "length"):
98+
raise Exception("RTO: provider returned an empty, None, or truncated response for the second code (C2)")
8499
c2 = response_c2.choices[0].message.content
85100
rto_completion_tokens += response_c2.usage.completion_tokens
86101

@@ -106,6 +121,11 @@ def round_trip_optimization(system_prompt: str, initial_query: str, client, mode
106121
response_dict = response_c3.model_dump() if hasattr(response_c3, 'model_dump') else response_c3
107122
optillm.conversation_logger.log_provider_call(request_id, provider_request, response_dict)
108123

124+
if (response_c3 is None or
125+
not response_c3.choices or
126+
response_c3.choices[0].message.content is None or
127+
response_c3.choices[0].finish_reason == "length"):
128+
raise Exception("RTO: provider returned an empty, None, or truncated response for the final code (C3)")
109129
c3 = response_c3.choices[0].message.content
110130
rto_completion_tokens += response_c3.usage.completion_tokens
111131

‎optillm/self_consistency.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ def generate_responses(self, system_prompt: str, user_prompt: str) -> List[str]:
3939
response_dict = response.model_dump() if hasattr(response, 'model_dump') else response
4040
optillm.conversation_logger.log_provider_call(self.request_id, provider_request, response_dict)
4141

42+
# Skip empty, None, or length-truncated samples so they do not flow
43+
# into similarity clustering (a None content crashes SequenceMatcher).
44+
if (response is None or
45+
not response.choices or
46+
response.choices[0].message.content is None or
47+
response.choices[0].finish_reason == "length"):
48+
logger.warning("Self-consistency sample was empty, None, or truncated, skipping")
49+
continue
50+
4251
self.self_consistency_completion_tokens += response.usage.completion_tokens
4352
responses.append(response.choices[0].message.content)
4453
return responses

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "optillm"
7-
version = "0.3.15"
7+
version = "0.3.16"
88
description = "An optimizing inference proxy for LLMs."
99
readme = "README.md"
1010
license = "Apache-2.0"

‎requirements.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ z3-solver<=4.15.4.0
55
aiohttp
66
flask
77
torch
8-
transformers
8+
transformers>=5.0.0
99
azure.identity
1010
tiktoken
1111
scikit-learn

‎tests/test.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ def run_approach(approach_name: str, system_prompt: str, query: str, client, mod
7979
response = client.chat.completions.create(
8080
model=model,
8181
messages=messages,
82-
temperature=0.7
82+
temperature=0.7,
83+
# Cap output: the small local test model does not always emit an
84+
# EOS token on simple prompts, so bound generation to keep tests fast.
85+
max_tokens=64
8386
)
8487
result = (response.choices[0].message.content, response.usage.total_tokens)
8588
else:

‎tests/test_approaches.py‎

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from optillm.cot_reflection import cot_reflection
1717
from optillm.plansearch import plansearch
1818
from optillm.leap import leap
19+
from optillm.rto import round_trip_optimization
1920
from optillm.mars import multi_agent_reasoning_system
2021

2122

@@ -34,15 +35,62 @@ class MockChoice:
3435
class Message:
3536
content = "Test response: 2 + 2 = 4"
3637
message = Message()
37-
38+
finish_reason = "stop"
39+
3840
class MockUsage:
3941
completion_tokens = 10
4042
total_tokens = 20
41-
43+
4244
class MockResponse:
4345
choices = [MockChoice()]
4446
usage = MockUsage()
45-
47+
48+
return MockResponse()
49+
50+
51+
class MockBadClient:
52+
"""Mock client that simulates degraded provider responses.
53+
54+
mode:
55+
- "empty_choices": response with an empty choices list
56+
- "none_content": a choice whose message.content is None
57+
- "length": a choice truncated with finish_reason == "length"
58+
"""
59+
def __init__(self, mode):
60+
self.chat = self.Chat(mode)
61+
62+
class Chat:
63+
def __init__(self, mode):
64+
self.completions = self.Completions(mode)
65+
66+
class Completions:
67+
def __init__(self, mode):
68+
self.mode = mode
69+
70+
def create(self, **kwargs):
71+
mode = self.mode
72+
73+
class MockUsage:
74+
completion_tokens = 0
75+
total_tokens = 0
76+
77+
if mode == "empty_choices":
78+
class MockResponse:
79+
choices = []
80+
usage = MockUsage()
81+
return MockResponse()
82+
83+
class MockMessage:
84+
content = None if mode == "none_content" else "partial output"
85+
86+
class MockChoice:
87+
message = MockMessage()
88+
finish_reason = "length" if mode == "length" else "stop"
89+
90+
class MockResponse:
91+
choices = [MockChoice()]
92+
usage = MockUsage()
93+
4694
return MockResponse()
4795

4896

@@ -121,11 +169,50 @@ def test_approach_parameters():
121169
print(f"✅ {name} has correct parameters")
122170

123171

172+
def test_approaches_handle_bad_responses():
173+
"""Approaches must degrade gracefully on empty / None / length-truncated
174+
provider responses instead of raising raw IndexError/TypeError/AttributeError.
175+
176+
Without the response-validation guards these calls crash with an
177+
uncaught IndexError (empty choices) or TypeError/AttributeError (None content
178+
flowing into extract_output / SequenceMatcher / .strip()).
179+
"""
180+
system_prompt = "You are a helpful assistant."
181+
query = "What is 2 + 2?"
182+
model = "mock-model"
183+
184+
uncontrolled = (IndexError, TypeError, AttributeError)
185+
approaches = [
186+
("round_trip_optimization", round_trip_optimization),
187+
("advanced_self_consistency_approach", advanced_self_consistency_approach),
188+
("re2_approach", re2_approach),
189+
("leap", leap),
190+
]
191+
192+
for mode in ("empty_choices", "none_content", "length"):
193+
client = MockBadClient(mode)
194+
for name, func in approaches:
195+
try:
196+
result = func(system_prompt, query, client, model)
197+
# Graceful degradation: a normal (content, tokens) tuple.
198+
assert isinstance(result, tuple) and len(result) == 2, \
199+
f"{name} ({mode}) returned unexpected {result!r}"
200+
except uncontrolled as e:
201+
raise AssertionError(
202+
f"{name} ({mode}) raised uncontrolled {type(e).__name__}: {e}")
203+
except Exception:
204+
# A controlled, informative error is acceptable hardening behaviour.
205+
pass
206+
207+
print("✅ approaches handle bad responses gracefully")
208+
209+
124210
if __name__ == "__main__":
125211
print("Running approach tests...")
126-
212+
127213
test_approach_imports()
128214
test_basic_approach_calls()
129215
test_approach_parameters()
130-
216+
test_approaches_handle_bad_responses()
217+
131218
print("\nAll tests completed!")

0 commit comments

Comments
 (0)