Skip to content

Commit 0b5eef9

Browse files
authored
Merge pull request #6 from reward-protocol/sync-code-changes
sync code change on async reward func
2 parents ea8a3cb + 8087df8 commit 0b5eef9

4 files changed

Lines changed: 151 additions & 137 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ requires = ["setuptools>=61.0", "versioneer[toml]>=0.29"]
33
build-backend = "setuptools.build_meta"
44

55
[project]
6-
name = "reward-kit"
6+
name = "reward-protocol"
77
dynamic = ["version"]
88
authors = [
99
{name = "Fireworks AI", email = "info@fireworks.ai"},

reward_kit/typed_interface.py

Lines changed: 101 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def reward_function(
4343
id: Optional[str] = None,
4444
requirements: Optional[List[str]] = None, # Changed to List[str]
4545
resources: Optional[ResourceDict] = None, # Resource management
46+
concurrency: Optional[int] = None,
47+
timeout: Optional[int] = None,
4648
) -> Union[F, Callable[[F], F]]:
4749
"""
4850
Decorator for user-defined reward and evaluation functions with resource management.
@@ -64,6 +66,8 @@ def reward_function(
6466
resources: Optional dictionary of resource types to resource instances.
6567
Example: {"llms": [llm_resource]}
6668
Resources are automatically setup before evaluation and cleaned up after.
69+
concurrency: Optional number of concurrent requests to the reward function. This will only take effect if the function is async or there are async resources binded to the reward function (e.g. LLM resource).
70+
timeout: Optional timeout for the reward function. This will only take effect if the function is async or there are async resources binded to the reward function (e.g. LLM resource).
6771
6872
Returns:
6973
A decorator if `_func` is None, or the decorated function.
@@ -94,38 +98,14 @@ def decorator(func: F) -> F:
9498
managers.append(resource)
9599
resource_managers[resource_type] = managers
96100

97-
@wraps(func)
98-
def wrapper(
99-
# The wrapper's signature should be generic enough to accept what the original func might take.
100-
# The specific coercion below targets 'messages' and 'ground_truth'.
101-
# For 'pointwise', 'messages' is expected. For 'batch', 'rollouts_messages' is more typical.
102-
# This simplified wrapper signature might need adjustment if we generalize input param coercion.
103-
*args: Any,
104-
**kwargs: Any,
105-
) -> Union[EvaluateResult, List[EvaluateResult]]: # Return type depends on mode
106-
107-
# For now, we'll assume 'messages' is the primary input for pointwise,
108-
# and it's passed via kwargs or as the first arg if not named 'messages'.
109-
# This part needs to be more robust based on actual function signatures.
110-
# The current reverted logic specifically looks for 'messages' in params.
111-
112-
# Extract 'messages' or 'rollouts_messages' from args/kwargs for processing
113-
# This is a simplification; a more robust solution would inspect `sig`
114-
# to find the correct parameter name based on `mode`.
115-
# For now, sticking to the structure of the reverted file which explicitly handles 'messages'.
116-
117-
current_messages_arg_name = "messages" # Default for pointwise
118-
if mode == "batch":
119-
# A common pattern for batch mode is 'rollouts_messages'
120-
# We'd need to find this in *args or **kwargs if not hardcoded.
121-
# For now, let's assume it's still passed as 'messages' for simplicity of this step,
122-
# or that the user function handles it. The key is output validation.
123-
pass
124-
125-
# The reverted logic for input coercion:
126-
# It explicitly looks for 'messages' and 'ground_truth' in params.
127-
# We need to adapt how `processed_messages` and `kwargs` are prepared for `func` call.
101+
# Detect if the user supplied function is a coroutine (async def)
102+
_is_async_function = inspect.iscoroutinefunction(func)
128103

104+
def _prepare_final_args(*args: Any, **kwargs: Any):
105+
"""Prepare final positional and keyword arguments for the user function call.
106+
This includes Pydantic coercion and resource injection. Returns a tuple of
107+
(call_args, call_kwargs).
108+
"""
129109
# Bind arguments to handle *args and **kwargs correctly for the wrapped function
130110
bound_args = sig.bind_partial(*args, **kwargs)
131111
bound_args.apply_defaults()
@@ -159,13 +139,9 @@ def _coerce_to_list_message(
159139
and "messages" in final_func_args
160140
):
161141
messages_param_annotation = params["messages"].annotation
162-
is_list_message_hint = False
163-
if get_origin(messages_param_annotation) in (list, List):
164-
ann_args = get_args(messages_param_annotation)
165-
if ann_args and ann_args[0] == Message:
166-
is_list_message_hint = True
167-
168-
if is_list_message_hint:
142+
if get_origin(messages_param_annotation) in (list, List) and get_args(
143+
messages_param_annotation
144+
) and get_args(messages_param_annotation)[0] == Message:
169145
try:
170146
final_func_args["messages"] = _coerce_to_list_message(
171147
final_func_args["messages"], "messages"
@@ -181,72 +157,36 @@ def _coerce_to_list_message(
181157
and "rollouts_messages" in final_func_args
182158
):
183159
param_annotation = params["rollouts_messages"].annotation
184-
is_list_list_msg_hint = False
185-
# Check if the annotation is List[List[Message]]
186-
# get_origin(typing.List[T]) is list.
187-
if get_origin(param_annotation) == list:
188-
inner_list_type_args = get_args(param_annotation)
189-
if inner_list_type_args and len(inner_list_type_args) == 1:
190-
inner_list_type = inner_list_type_args[0]
191-
if get_origin(inner_list_type) == list:
192-
msg_type_args = get_args(inner_list_type)
193-
if (
194-
msg_type_args
195-
and len(msg_type_args) == 1
196-
and msg_type_args[0] == Message
197-
):
198-
is_list_list_msg_hint = True
199-
200-
if is_list_list_msg_hint:
201-
try:
202-
coerced_rollouts = []
203-
for i, rollout_data in enumerate(
204-
final_func_args["rollouts_messages"]
205-
):
206-
coerced_rollouts.append(
207-
_coerce_to_list_message(
208-
rollout_data, f"rollouts_messages[{i}]"
160+
inner = get_args(param_annotation)[0] if get_args(param_annotation) else None
161+
if get_origin(param_annotation) == list and inner and get_origin(inner) == list:
162+
if get_args(inner) and get_args(inner)[0] == Message:
163+
try:
164+
coerced_rollouts = []
165+
for i, rollout_data in enumerate(final_func_args["rollouts_messages"]):
166+
coerced_rollouts.append(
167+
_coerce_to_list_message(
168+
rollout_data, f"rollouts_messages[{i}]"
169+
)
209170
)
210-
)
211-
final_func_args["rollouts_messages"] = coerced_rollouts
212-
except Exception as err:
213-
raise ValueError(
214-
f"Input 'rollouts_messages' failed Pydantic validation: {err}"
215-
) from None
171+
final_func_args["rollouts_messages"] = coerced_rollouts
172+
except Exception as err:
173+
raise ValueError(
174+
f"Input 'rollouts_messages' failed Pydantic validation: {err}"
175+
) from None
216176

217-
# 2. Conditional Pydantic conversion for 'ground_truth' (applies to both modes if present)
218-
# This logic might need to be mode-aware if ground_truth structure changes with mode (e.g. List[Any] for batch)
177+
# Ground truth coercion (if needed)
219178
if "ground_truth" in params and "ground_truth" in final_func_args:
220-
ground_truth_param_annotation = params["ground_truth"].annotation
221-
ground_truth_data = final_func_args["ground_truth"]
222-
223-
is_list_message_gt_hint = False
224-
if get_origin(ground_truth_param_annotation) in (list, List):
225-
ann_args = get_args(ground_truth_param_annotation)
226-
if ann_args and ann_args[0] == Message:
227-
is_list_message_gt_hint = True
228-
229-
if is_list_message_gt_hint and ground_truth_data is not None:
230-
if not isinstance(ground_truth_data, list):
231-
raise TypeError(
232-
f"'ground_truth' expected a list for List[Message] hint, got {type(ground_truth_data)}"
233-
)
234-
try:
235-
typed_ground_truth_list = []
236-
for gt_item_data in ground_truth_data:
237-
if isinstance(gt_item_data, Message):
238-
typed_ground_truth_list.append(gt_item_data)
239-
elif isinstance(gt_item_data, dict):
240-
typed_ground_truth_list.append(Message(**gt_item_data))
241-
else:
242-
raise TypeError(
243-
f"Unexpected type in ground_truth list: {type(gt_item_data)}"
244-
)
245-
final_func_args["ground_truth"] = typed_ground_truth_list
246-
except Exception as err:
247-
raise ValueError(
248-
f"Input 'ground_truth' failed Pydantic validation for List[Message]: {err}"
249-
) from None
179+
gt_ann = params["ground_truth"].annotation
180+
if get_origin(gt_ann) in (list, List) and get_args(gt_ann) and get_args(gt_ann)[0] == Message:
181+
if final_func_args["ground_truth"] is not None:
182+
try:
183+
final_func_args["ground_truth"] = _coerce_to_list_message(
184+
final_func_args["ground_truth"], "ground_truth"
185+
)
186+
except Exception as err:
187+
raise ValueError(
188+
f"Input 'ground_truth' failed Pydantic validation for List[Message]: {err}"
189+
) from None
250190

251191
# Inject resource clients into kwargs (resources are already setup)
252192
if resource_managers:
@@ -258,8 +198,8 @@ def _coerce_to_list_message(
258198
# Call the author's function using the (potentially modified) arguments dictionary.
259199
# final_func_args should contain all parameters expected by func, correctly mapped.
260200
# Reconstruct args and kwargs for the call to func
261-
call_args = []
262-
call_kwargs = {}
201+
call_args: List[Any] = []
202+
call_kwargs: Dict[str, Any] = {}
263203
for (
264204
p_name,
265205
p_obj,
@@ -276,36 +216,67 @@ def _coerce_to_list_message(
276216
else: # POSITIONAL_OR_KEYWORD, KEYWORD_ONLY
277217
call_kwargs[p_name] = final_func_args[p_name]
278218

279-
result = func(*call_args, **call_kwargs)
280-
281-
# --- Output Validation ---
282-
try:
283-
if mode == "pointwise":
284-
if isinstance(result, EvaluateResult): # Already correct type
285-
return result
286-
return _single_res_adapter.validate_python(result)
287-
elif mode == "batch":
288-
if isinstance(result, list) and all(
289-
isinstance(item, EvaluateResult) for item in result
290-
):
291-
return result
292-
return _list_res_adapter.validate_python(result)
293-
else: # Should not happen due to EvaluationMode typing
219+
return call_args, call_kwargs
220+
221+
def _validate_output(result: Any):
222+
if mode == "pointwise":
223+
if isinstance(result, EvaluateResult):
224+
return result
225+
return _single_res_adapter.validate_python(result)
226+
elif mode == "batch":
227+
if isinstance(result, list) and all(
228+
isinstance(item, EvaluateResult) for item in result
229+
):
230+
return result
231+
return _list_res_adapter.validate_python(result)
232+
else:
233+
raise ValueError(f"Internal error: Invalid mode '{mode}' in wrapper.")
234+
235+
if _is_async_function:
236+
237+
@wraps(func)
238+
async def async_wrapper(
239+
*args: Any,
240+
**kwargs: Any,
241+
) -> Union[EvaluateResult, List[EvaluateResult]]:
242+
call_args, call_kwargs = _prepare_final_args(*args, **kwargs)
243+
result = await func(*call_args, **call_kwargs) # type: ignore[misc]
244+
try:
245+
return _validate_output(result)
246+
except ValidationError as err:
294247
raise ValueError(
295-
f"Internal error: Invalid mode '{mode}' in wrapper."
296-
)
297-
except ValidationError as err:
298-
raise ValueError(
299-
f"Return value from function '{func.__name__}' failed Pydantic validation for mode '{mode}':\n{err}"
300-
) from None
248+
f"Return value from function '{func.__name__}' failed Pydantic validation for mode '{mode}':\n{err}"
249+
) from None
250+
251+
wrapper_fn = async_wrapper
252+
253+
else:
254+
255+
@wraps(func)
256+
def sync_wrapper(
257+
*args: Any,
258+
**kwargs: Any,
259+
) -> Union[EvaluateResult, List[EvaluateResult]]:
260+
call_args, call_kwargs = _prepare_final_args(*args, **kwargs)
261+
result = func(*call_args, **call_kwargs)
262+
try:
263+
return _validate_output(result)
264+
except ValidationError as err:
265+
raise ValueError(
266+
f"Return value from function '{func.__name__}' failed Pydantic validation for mode '{mode}':\n{err}"
267+
) from None
301268

302-
# Set attributes for introspection and deployment
303-
wrapper._reward_function_id = id
304-
wrapper._reward_function_requirements = requirements
305-
wrapper._reward_function_mode = mode
306-
wrapper._reward_function_resources = resources
269+
wrapper_fn = sync_wrapper
307270

308-
return cast(F, wrapper)
271+
# Set attributes for introspection and deployment
272+
wrapper_fn._reward_function_id = id # type: ignore[attr-defined]
273+
wrapper_fn._reward_function_requirements = requirements # type: ignore[attr-defined]
274+
wrapper_fn._reward_function_mode = mode # type: ignore[attr-defined]
275+
wrapper_fn._reward_function_resources = resources # type: ignore[attr-defined]
276+
wrapper_fn._reward_function_timeout = timeout # type: ignore[attr-defined]
277+
wrapper_fn._reward_function_concurrency = concurrency # type: ignore[attr-defined]
278+
279+
return cast(F, wrapper_fn)
309280

310281
if (
311282
_func is None

tests/test_reward_protocol_import.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,19 +162,19 @@ def test_console_scripts_in_setup(self):
162162
import os
163163

164164
# Read setup.py content directly to avoid running it
165-
setup_path = os.path.join(os.path.dirname(__file__), '..', 'setup.py')
165+
setup_path = os.path.join(os.path.dirname(__file__), '..', 'pyproject.toml')
166166
with open(setup_path, 'r') as f:
167167
setup_content = f.read()
168168

169169
# Check for console scripts in the file content
170170
expected_scripts = [
171-
'fireworks-reward=reward_kit.cli:main',
172-
'reward-kit=reward_kit.cli:main',
173-
'reward-protocol=reward_kit.cli:main',
171+
'fireworks-reward = "reward_kit.cli:main"',
172+
'reward-kit = "reward_kit.cli:main"',
173+
'reward-protocol = "reward_kit.cli:main"',
174174
]
175175

176176
for script in expected_scripts:
177-
assert script in setup_content, f"Console script '{script}' not found in setup.py"
177+
assert script in setup_content, f"Console script '{script}' not found in pyproject.toml"
178178

179179
def test_package_structure_in_setup(self):
180180
"""Test that both packages are included in setup.py."""

tests/test_typed_interface.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Tests for the typed interface functionality.
33
"""
44

5+
import asyncio
56
from typing import Any, Dict, List
67

78
import pytest
@@ -130,7 +131,7 @@ def invalid_evaluator(messages: List[Message], **kwargs) -> EvaluateResult:
130131
score=0.5,
131132
reason=None,
132133
metrics={
133-
"test": {"score": 0.5}, # type: ignore[dict-item] # Missing 'success' and 'reason'
134+
"test": {"score": 0.5}, # type: ignore[dict-item] # Missing 'success'
134135
},
135136
) # type: ignore
136137

@@ -229,3 +230,45 @@ def sample_evaluator(messages: List[Message], **kwargs) -> EvaluateResult:
229230
assert metric_test_dict_access["is_score_valid"] is True
230231
assert metric_test_dict_access["score"] == 0.8
231232
assert metric_test_dict_access["reason"] == "Test reason"
233+
234+
235+
def test_async_reward_function():
236+
"""Test that the typed_interface works with async functions."""
237+
@reward_function
238+
async def async_evaluator(messages: List[Message], **kwargs) -> EvaluateResult:
239+
"""Sample async evaluator that returns a hardcoded result."""
240+
return EvaluateResult(
241+
score=0.8,
242+
reason="Overall test reason",
243+
is_score_valid=True
244+
)
245+
246+
async def _test_async_reward_function():
247+
messages = [
248+
{"role": "user", "content": "Hello"},
249+
{"role": "assistant", "content": "Hi there!"},
250+
]
251+
252+
result = await async_evaluator(messages=messages)
253+
assert isinstance(result, EvaluateResult)
254+
assert result.score == 0.8
255+
assert result.reason == "Overall test reason"
256+
257+
asyncio.run(_test_async_reward_function())
258+
259+
def test_reward_function_decorator_attributes():
260+
"""Test that the reward_function decorator sets attributes correctly."""
261+
262+
@reward_function(mode="batch", requirements=["requests", "numpy"], concurrency=10, timeout=10)
263+
def sample_evaluator(messages: List[Message], **kwargs) -> EvaluateResult:
264+
"""Sample evaluator that returns a hardcoded result."""
265+
return EvaluateResult(
266+
score=0.8,
267+
reason="Overall test reason",
268+
metrics={}
269+
)
270+
271+
assert sample_evaluator._reward_function_mode == "batch"
272+
assert sample_evaluator._reward_function_requirements == ["requests", "numpy"]
273+
assert sample_evaluator._reward_function_concurrency == 10
274+
assert sample_evaluator._reward_function_timeout == 10

0 commit comments

Comments
 (0)