Skip to content

Commit 144033a

Browse files
committed
fix(evaluator): detect structured output support from the endpoint
Structured output mode was chosen from `Model.format`, but support is a property of the endpoint, not of the label attached to the model. integrate.api.nvidia.com is served under the `nim` label yet rejects `nvext.guided_json` with a 400 and silently ignores root `guided_json`, while honouring OpenAI `response_format`. Preflight probed only the two guided_json placements for `nim`, so it resolved to UNSUPPORTED and fell back to an unenforced "return JSON" prompt instruction. Judges kept scoring and nothing surfaced the downgrade. Probe every endpoint with the same ordered candidate list, trying `response_format` first. `Model.format` is deprecated, ignored, and now defaults to `openai`. Detection is cached per run behind a re-entrant ContextVar session. Run scoping is what makes caching a negative result safe: a probe can fail for reasons unrelated to capability, and a process-global negative would disable enforcement until restart. Five generation paths built a hook and issued requests without probing: target generation, agent evaluation, the multi-metric benchmark path, ProfBench, and direct `compute_scores()`. All now resolve first, and a hook used unprobed logs a warning once so a future path is visible rather than silent. The default `max_tokens` cap was NIM-only, so flipping the default format would have silently removed it. It is now unconditional, which changes behaviour for openai-format models that never set it. Signed-off-by: Sandy Chapman <schapman@nvidia.com>
1 parent eaae615 commit 144033a

37 files changed

Lines changed: 1698 additions & 224 deletions

File tree

docs/evaluator/agent-eval/targets-and-runners.mdx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,14 @@ bare model do before you wrap it in an agent?). The evaluator prompts it with ea
3434
|---|---|---|
3535
| `url` | yes | endpoint URL (e.g. `.../v1/chat/completions` or `.../v1/completions`) |
3636
| `name` | yes | model identifier, stamped on trials |
37-
| `format` | no | `ModelFormat.NVIDIA_NIM` (default), `ModelFormat.OPEN_AI`, or `ModelFormat.LLAMA_STACK` — serialized as `nim` / `openai` / `llama_stack` |
37+
| `format` | no | **deprecated and ignored** — structured output support is probed from the endpoint during preflight |
3838
| `api_key_secret` | no | credential reference — `workspace/secret_name` or `secret_name` |
3939

4040
```python
41-
from nemo_evaluator_sdk.enums import ModelFormat
4241
from nemo_evaluator_sdk.values import Model
4342

4443
target = Model(url="https://integrate.api.nvidia.com/v1/chat/completions", name="meta/llama-3.1-8b-instruct",
45-
format=ModelFormat.OPEN_AI, api_key_secret="NVIDIA_API_KEY")
44+
api_key_secret="NVIDIA_API_KEY")
4645
```
4746

4847
For a local `run()`, `api_key_secret` names an **environment variable** in your process; for a submitted

docs/evaluator/manage-tasks-tasksets.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,13 +317,12 @@ each run.
317317
from nemo_evaluator.api.schemas import TasksetRef
318318
from nemo_evaluator.jobs.agent_spec import AgentEvalInputSpec, ModelTarget
319319
from nemo_evaluator_sdk.values import Model
320-
from nemo_evaluator_sdk.enums import ModelFormat
321320

322321
# Instead of inlining AgentEvalTaskInput objects, point `tasks` at a stored taskset.
323322
input_spec = AgentEvalInputSpec(
324323
tasks=TasksetRef("default/geography-suite"),
325324
target=ModelTarget(
326-
model=Model(url="https://integrate.api.nvidia.com/v1", name="meta/llama-3.3-70b-instruct", format=ModelFormat.OPEN_AI),
325+
model=Model(url="https://integrate.api.nvidia.com/v1", name="meta/llama-3.3-70b-instruct"),
327326
),
328327
)
329328
```

docs/evaluator/metrics/llm-as-a-judge.mdx

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,11 @@ from nemo_evaluator_sdk import (
7171
RunConfig,
7272
LLMJudgeMetric
7373
)
74-
from nemo_evaluator_sdk.enums import ModelFormat
7574

7675
metric = LLMJudgeMetric(
7776
model=Model(
7877
url="<judge-nim-url>/v1",
7978
name="meta/llama-3.1-70b-instruct",
80-
format=ModelFormat.NVIDIA_NIM,
8179
),
8280
scores=[
8381
RangeScore(
@@ -169,15 +167,13 @@ Use rubric scores when you want categorical labels with explicit descriptions:
169167

170168
```python
171169
from nemo_evaluator_sdk import JSONScoreParser, Model, RubricScore, LLMJudgeMetric
172-
from nemo_evaluator_sdk.enums import ModelFormat
173170
from nemo_evaluator_sdk.values import Rubric
174171
from nemo_evaluator_sdk import Evaluator as LocalEvaluator
175172

176173
metric = LLMJudgeMetric(
177174
model=Model(
178175
url="<judge-nim-url>/v1",
179176
name="meta/llama-3.1-70b-instruct",
180-
format=ModelFormat.NVIDIA_NIM,
181177
),
182178
scores=[
183179
RubricScore(
@@ -279,14 +275,12 @@ For production workloads, submit the same metric and dataset as a durable platfo
279275

280276
```python
281277
from nemo_evaluator_sdk import RunConfig, JSONScoreParser, Model, RubricScore, LLMJudgeMetric
282-
from nemo_evaluator_sdk.enums import ModelFormat
283278
from nemo_evaluator_sdk.values import Rubric
284279

285280
metric = LLMJudgeMetric(
286281
model=Model(
287282
url="<judge-nim-url>/v1",
288283
name="meta/llama-3.1-70b-instruct",
289-
format=ModelFormat.NVIDIA_NIM,
290284
),
291285
scores=[
292286
RubricScore(
@@ -552,7 +546,6 @@ metric = {
552546
"model": {
553547
"url": "<judge-url>/v1",
554548
"name": "meta/llama-3.1-70b-instruct",
555-
"format": "nim"
556549
},
557550
"scores": [
558551
{
@@ -599,7 +592,6 @@ metric = {
599592
"model": {
600593
"url": "https://api.example.com/v1",
601594
"name": "gpt-4",
602-
"format": "openai",
603595
"api_key_secret": "judge-api-key",
604596
},
605597
# ... scores and prompt_template
@@ -638,7 +630,6 @@ metric = {
638630
"model": {
639631
"url": "<nim-url>/v1",
640632
"name": "nvidia/llama-3.3-nemotron-super-49b-v1",
641-
"format": "nim",
642633
},
643634
# ... scores ...
644635
"system_prompt": "'detailed thinking on'",

docs/evaluator/metrics/model-configuration.mdx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ from nemo_evaluator_sdk import Model
3636
model = Model(
3737
url="https://integrate.api.nvidia.com/v1",
3838
name="meta/llama-3.1-70b-instruct",
39-
format="nim",
4039
api_key_secret="nvidia-api-key",
4140
)
4241
```
@@ -45,7 +44,7 @@ model = Model(
4544
|-------|----------|-------------|
4645
| `url` | Yes | Base URL of the inference endpoint. |
4746
| `name` | Yes | Model name to send in inference requests. |
48-
| `format` | No | API format: `"nim"`, `"openai"`, or `"llama_stack"`. Defaults to `"nim"`. |
47+
| `format` | No | **Deprecated and ignored.** Structured output support is detected from the endpoint during preflight rather than inferred from this label. |
4948
| `api_key_secret` | No | Model API key reference. See [Model API Authentication](#model-api-authentication). |
5049

5150
<a id="model-api-authentication"></a>
@@ -80,7 +79,6 @@ from nemo_evaluator_sdk import (
8079
model = Model(
8180
url="https://integrate.api.nvidia.com/v1",
8281
name="meta/llama-3.1-70b-instruct",
83-
format="nim",
8482
api_key_secret="nvidia-api-key",
8583
)
8684

@@ -112,7 +110,6 @@ from nemo_evaluator_sdk import Model, RangeScore, LLMJudgeMetric
112110
judge_model = Model(
113111
url="https://integrate.api.nvidia.com/v1",
114112
name="meta/llama-3.1-70b-instruct",
115-
format="nim",
116113
api_key_secret="nvidia-api-key",
117114
)
118115
metric = LLMJudgeMetric(

docs/notebooks/ndd_evaluator.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,6 @@ def run_evaluation(
574574
model_kwargs = {
575575
"url": model_spec["url"],
576576
"name": model_spec["model_id"],
577-
"format": "openai",
578577
}
579578
if secret_key_name:
580579
model_kwargs["api_key_secret"] = secret_key_name

packages/nemo_evaluator_sdk/examples/agentic_eval_with_fabric.ipynb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,6 @@
411411
" model=Model(\n",
412412
" url=\"https://integrate.api.nvidia.com/v1/chat/completions\",\n",
413413
" name=os.environ.get(\"JUDGE_MODEL\", \"nvidia/nvidia-nemotron-nano-9b-v2\"),\n",
414-
" format=\"openai\",\n",
415414
" # api_key_secret names the env var to read; model.api_key resolves os.environ[JUDGE_API_KEY_ENV].\n",
416415
" api_key_secret=SecretRef(JUDGE_API_KEY_ENV),\n",
417416
" ),\n",

packages/nemo_evaluator_sdk/examples/profbench/profbench.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
from nemo_evaluator_sdk.agent_eval.scores import AgentEvalTaskScore
2121
from nemo_evaluator_sdk.agent_eval.tasks import AgentEvalTask
2222
from nemo_evaluator_sdk.agent_eval.trials import AgentEvalTrial, AgentEvalTrialStatus, AgentOutput
23-
from nemo_evaluator_sdk.execution.metric_execution import generate_online_sample
23+
from nemo_evaluator_sdk.execution.metric_execution import (
24+
generate_online_sample,
25+
resolve_target_structured_output_mode,
26+
)
2427
from nemo_evaluator_sdk.metrics.protocol import MetricInput, MetricOutput, MetricOutputSpec, MetricResult
2528
from nemo_evaluator_sdk.values import InferenceParams, Model, RunConfigOnlineModel
2629
from nemo_evaluator_sdk.values.evidence import CandidateEvidence, EvidenceDescriptor
@@ -246,7 +249,14 @@ def __init__(
246249
self.default_headers = default_headers
247250

248251
async def judge(self, request: ProfBenchJudgeRequest) -> ProfBenchJudgeDecision:
249-
preprocess_hooks, postprocess_hooks = inference.new_hooks(self.params, model_format=self.model.format)
252+
preprocess_hooks, postprocess_hooks = inference.new_hooks(self.params)
253+
# Hooks are rebuilt per judge call, so this relies on detection being cached per endpoint.
254+
await resolve_target_structured_output_mode(
255+
preprocess_hooks=preprocess_hooks,
256+
model=self.model,
257+
inference_fn=self.inference_fn,
258+
params=self.params,
259+
)
250260
sample = await generate_online_sample(
251261
target=self.model,
252262
row={"prompt": _render_judge_prompt(request)},

packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/evaluator.py

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,15 @@
4646
make_agent_inference_fn,
4747
new_agent_inference_client,
4848
)
49-
from nemo_evaluator_sdk.execution.metric_execution import generate_online_sample, run_sync
49+
from nemo_evaluator_sdk.execution.metric_execution import (
50+
generate_online_sample,
51+
resolve_target_structured_output_mode,
52+
run_sync,
53+
)
54+
from nemo_evaluator_sdk.structured_output import structured_output_mode_session
5055
from nemo_evaluator_sdk.execution.samples import build_metric_input
5156
from nemo_evaluator_sdk.inference import InferenceFn
52-
from nemo_evaluator_sdk.metrics.protocol import Metric, validate_metric_result
57+
from nemo_evaluator_sdk.metrics.protocol import Metric, MetricWithPreflight, validate_metric_result
5358
from nemo_evaluator_sdk.metrics.utils import metric_type_name
5459
from nemo_evaluator_sdk.values import (
5560
Agent,
@@ -161,22 +166,26 @@ async def run(
161166
runtime_config = resolved_config.model_copy(update={"run_id": run_id})
162167
started_at = datetime.now(UTC)
163168

164-
# Branch on which seam was supplied so the type checker can narrow ``target`` to a
165-
# concrete ``AgentEvalTarget`` without a cast.
166-
if trials is not None:
167-
if target is not None:
169+
# One detection session for the whole run: generation probes the target and scoring probes
170+
# any judge model, and imported-trial runs still score, so scoping this to generation alone
171+
# would leave judges probing per call.
172+
async with structured_output_mode_session():
173+
# Branch on which seam was supplied so the type checker can narrow ``target`` to a
174+
# concrete ``AgentEvalTarget`` without a cast.
175+
if trials is not None:
176+
if target is not None:
177+
raise ValueError("provide exactly one of trials or target")
178+
trial_list = list(trials)
179+
elif target is not None:
180+
trial_list = await self._generate_trials(tasks=task_list, target=target, config=runtime_config)
181+
else:
168182
raise ValueError("provide exactly one of trials or target")
169-
trial_list = list(trials)
170-
elif target is not None:
171-
trial_list = await self._generate_trials(tasks=task_list, target=target, config=runtime_config)
172-
else:
173-
raise ValueError("provide exactly one of trials or target")
174-
scores = await self._score_trials(
175-
tasks=task_list,
176-
trials=trial_list,
177-
config=runtime_config,
178-
run_id=run_id,
179-
)
183+
scores = await self._score_trials(
184+
tasks=task_list,
185+
trials=trial_list,
186+
config=runtime_config,
187+
run_id=run_id,
188+
)
180189
runner_scores = _collect_runner_aggregate_scores(target) if target is not None else []
181190
finished_at = datetime.now(UTC)
182191
metadata = RunMetadata(
@@ -239,6 +248,19 @@ async def _score_trials(
239248
if not task.metrics:
240249
raise ValueError(f"task {task.id!r} does not declare any metrics")
241250

251+
# Agent-eval scores metrics directly rather than through prepare_metric_for_execution, so
252+
# nothing else runs their preflight. An LLM judge detects its endpoint's structured-output
253+
# encoding there; without this it would score using the provisional guess from new_hooks.
254+
# Deduplicated by identity because the same metric object is scored once per trial. The run
255+
# session would collapse repeat probes to one request anyway; this just avoids the repeated
256+
# awaits. Identity is stable here: `tasks` holds every metric for the duration of the loop.
257+
preflighted: set[int] = set()
258+
for task in tasks:
259+
for metric in task.metrics:
260+
if isinstance(metric, MetricWithPreflight) and id(metric) not in preflighted:
261+
preflighted.add(id(metric))
262+
await metric.preflight()
263+
242264
semaphore = asyncio.Semaphore(config.parallelism)
243265

244266
async def guarded_score(task: AgentEvalTask, trial: AgentEvalTrial, metric: Metric) -> AgentEvalTaskScore:
@@ -311,6 +333,8 @@ async def _generate_trials(
311333
params = _resolve_live_params(config, target)
312334
prompt_template = config.prompt_template or _default_prompt_template(target)
313335
semaphore = asyncio.Semaphore(params.parallelism)
336+
# Hooks are built per row below; the run-level session opened by run() is what keeps the
337+
# endpoint probe to one round trip for the whole pass instead of one per row.
314338

315339
# Use the injected transport client when provided; otherwise build a default for the
316340
# resolved target type and close it when generation finishes.
@@ -395,10 +419,18 @@ async def _generate_sample(
395419
# The transport client is a real class union, so isinstance narrowing is enough there.
396420
if isinstance(target, Model):
397421
model_params = cast(RunConfigOnlineModel, params)
398-
preprocess_hooks, postprocess_hooks = inference.new_hooks(model_params, model_format=target.format)
422+
preprocess_hooks, postprocess_hooks = inference.new_hooks(model_params)
399423
model_inference_fn = (
400424
cast(InferenceFn, inference_fn) if inference_fn is not None else inference.make_inference_request
401425
)
426+
# Hooks are built per row here, so this relies on detection being cached per endpoint:
427+
# without the probe the request would carry whichever encoding new_hooks guessed.
428+
await resolve_target_structured_output_mode(
429+
preprocess_hooks=preprocess_hooks,
430+
model=target,
431+
inference_fn=model_inference_fn,
432+
params=model_params,
433+
)
402434
return await generate_online_sample(
403435
target=target,
404436
row=row,

0 commit comments

Comments
 (0)