Skip to content

[OPIK-8012] [SDK] fix: bound LLM judge provider calls and retry empty responses - #7931

Open
AndreiCautisanu wants to merge 5 commits into
mainfrom
andreic/OPIK-8012-bound-llm-judge-retries
Open

[OPIK-8012] [SDK] fix: bound LLM judge provider calls and retry empty responses#7931
AndreiCautisanu wants to merge 5 commits into
mainfrom
andreic/OPIK-8012-bound-llm-judge-retries

Conversation

@AndreiCautisanu

@AndreiCautisanu AndreiCautisanu commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Details

Two SDK defects in the LiteLLM-backed LLM-judge path caused an intermittently failing E2E smoke test, plus one misleading error message that made them hard to diagnose. Impact is on any SDK user running evaluate / run_tests with an LLM judge, not just tests.

  • Empty structured-output responses were never retried (the cause of the flake). A response with neither content nor tool_calls raises BaseLLMError, but the judge's retry policy only caught LLMJudgeParseError — which extends OpikException, not BaseLLMError. The item lost its whole score even though the LLM call sits inside the retried _generate_and_parse and a retry would very likely have succeeded.
  • No timeout or retry ceiling reached litellm.completion. Neither timeout nor num_retries was forwarded, leaving litellm's 6000s (100 min) default plus its ~3 internal HTTP retries stacked on two existing tenacity layers. Thread dumps taken mid-hang show the stalls sit in TCP connect, so connect is bounded tighter (10s) than read (60s): a provider either accepts a connection promptly or is unreachable, whereas a long generation legitimately takes time. Both are setdefault, so explicit caller values still win.
  • The error blamed the API key. The key was valid in every observed case; the real condition is an empty response shape. It now reports model and finish_reason.

Measured cost of one stalled call: timeout=5 with num_retries unset took 50.8s versus 17.1s pinned; a blackholed connect went from ~180s to 32.2s.

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves #
  • OPIK-8012

Documentation

No documentation change needed. The new timeout / num_retries values are internal defaults that callers could already override by passing those kwargs to LiteLLMChatModel, and the retry-policy and error-message changes are not part of any documented API.

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5
  • Scope: Investigation, root-cause analysis, the three source fixes, and the four unit tests.
  • Human verification: Pending review. Every claim is backed by a cited measurement or captured stack trace; see the caveat below for the one gap.

Testing

cd sdks/python && pytest tests/unit/evaluation/models tests/unit/evaluation/suite_evaluators -q

321 passed, 2 skipped.

cd sdks/python && pytest tests/unit/evaluation -q

794 passed, 45 failed. Those 45 are pre-existing: stashing this branch's changes and re-running gives the identical 45 (missing optional deps, plus test-ordering pollution — two test_litellm_chat_model.py cases pass when the file runs scoped). This branch takes the suite from 790 to 794 passing.

pre-commit run --files <the 5 changed files>

→ ruff, ruff-format, mypy all Passed.

Each of the 4 new tests was confirmed to fail without its corresponding fix (revert, re-run, restore), so none is vacuous.

End-to-end against a local stack with a real Anthropic judge: runs of the affected smoke test that previously aborted at the 90s client timeout now pass.

Not fully verified

The trigger for the first defect — a provider returning a blank tool_use response — was never reproduced live. The unit test reproduces the exact exception path from the CI traceback and the fix is correct regardless, but confirming the flake is gone needs a few green CI runs after merge.

For reviewers: many of my local reproductions turned out to be a broken IPv6 route on my dev machine (IPv4 connect 0.01s, IPv6 timeout after 12s) — an environment problem, not an Opik defect. GitHub-hosted Linux runners are IPv4-only, so that cannot explain the CI failures; the retry gap is what does.

Deliberately unchanged: the E2E test's 90s client timeout. CI already burns ~95s per attempt across 3 attempts and healthy judge latency is ~1.1s median, so raising it would only make failures slower.

@AndreiCautisanu
AndreiCautisanu requested a review from a team as a code owner August 20, 2026 09:03
@github-actions github-actions Bot added python Pull requests that update Python code tests Including test files, or tests related like configuration. Python SDK labels Aug 20, 2026
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🐍 mypy — python sdk Static type check 1.01s
🐍 fix end of files — python sdk Ensure files end in a newline 0.04s
🐍 trim trailing whitespace — python sdk Strip trailing whitespace 0.04s
🐍 ruff-format — python sdk Format Python code (ruff) 0.01s
🐍 ruff — python sdk Lint + autofix Python (ruff) 0.01s
Total (5 ran) 1.11s
⏭️ 38 skipped (no matching files changed)
Hook Description Result
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
⚓ helm-docs Regenerate Helm chart README ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
☕ spotless — java backend Format Java code ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
🌐 eslint — frontend Lint + autofix JS/TS ⏭️
🌐 typecheck — frontend Whole-project tsc type check ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows ⏭️

@CometActions

CometActions commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

No test needed here.

Bounding the LiteLLM timeout/num_retries and retrying EmptyLLMResponseError only shows up when the provider hangs or returns nothing — neither is reproducible on a fresh OSS install without stubbing litellm, and the judge needs a provider key regardless. The one e2e spec on this path, test-suites/test-suites-smoke.spec.ts, skips without ANTHROPIC_API_KEY/OPENAI_API_KEY and asserts a happy-path 3/3 pass, so it could not observe these defaults either way. The unit tests you added (test_litellm_chat_model.py timeout/caller-override cases, test_llm_judge.py retries-and-succeeds vs permanent-error-not-retried) sit at the right level for this; nothing to add on top.

Run

Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review.

Re-checked after a push on 20 Aug 13:11 UTC — nothing the verdict depends on changed.

… responses

An empty structured-output response raises BaseLLMError, which the judge's
retry policy did not cover (LLMJudgeParseError extends OpikException, not
BaseLLMError), so a transient blank cost the item its whole score.

LiteLLMChatModel also forwarded no timeout and no num_retries, leaving
litellm's 6000s default plus its ~3 internal HTTP retries stacked under two
tenacity layers — a stalled provider blocked effectively forever. Observed
stalls sit in TCP connect, hence the tighter connect ceiling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndreiCautisanu
AndreiCautisanu force-pushed the andreic/OPIK-8012-bound-llm-judge-retries branch from 03be770 to 7983cd1 Compare August 20, 2026 09:13
Comment thread sdks/python/src/opik/evaluation/models/litellm/response_parser.py Outdated
Comment thread sdks/python/tests/unit/evaluation/models/test_litellm_chat_model.py Outdated
@AndreiCautisanu
AndreiCautisanu force-pushed the andreic/OPIK-8012-bound-llm-judge-retries branch from 03be770 to 7983cd1 Compare August 20, 2026 09:13
Comment thread sdks/python/src/opik/evaluation/models/litellm/response_parser.py Outdated
Address review: retrying every BaseLLMError also retried permanent auth,
invalid-request and config failures. Add EmptyLLMResponseError for the
transient case and retry only that.

The provider-response wrapper re-wrapped BaseLLMError subclasses, which
erased the new type and doubled the message prefix; let classified errors
through. _finish_reason now falls back to the raw choice, since
normalise_choice's non-pydantic path drops it. Drops the API-key guidance
from the empty-response message and renames the new tests to the
convention in .agents/skills/python-sdk/testing.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines 27 to 29
_RETRY_POLICY = tenacity.retry(
retry=tenacity.retry_if_exception_type(LLMJudgeParseError),
retry=tenacity.retry_if_exception_type((LLMJudgeParseError, EmptyLLMResponseError)),
stop=tenacity.stop_after_attempt(3),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agentic scoring still drops transient empty responses

The retry predicate at metric.py:27-29 covers only LLMJudge._generate_and_parse; LLMJudge.score routes agentic traces through _score_agentic(), whose AgenticLLMJudge._generate_and_parse retries only LLMJudgeParseError, so EmptyLLMResponseError from generate_chat_completion propagates immediately in agentic scoring while one-shot scoring retries it. Should we include EmptyLLMResponseError in the agentic retry policy and score handling?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/evaluation/suite_evaluators/llm_judge/metric.py` around lines
27-29, update the retry behavior for the agentic scoring path as well as the one-shot
`LLMJudge` path. Ensure `AgenticLLMJudge._generate_and_parse` retries
`EmptyLLMResponseError` alongside `LLMJudgeParseError`, and update `_score_agentic()`
error handling if needed so transient empty responses follow the same documented retry
contract instead of propagating immediately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — AgenticLLMJudge has its own _RETRY_POLICY at agentic/judge.py:34, so empty responses retried in one-shot scoring but propagated immediately in agentic scoring. Inconsistency was mine.

Added EmptyLLMResponseError to the agentic predicate so both paths behave identically. Exhausted-retry behaviour also matches: LLMJudgeParseError returns partial results via exc.results, everything else propagates — that's pre-existing in both paths, so I left it alone.

Test drives the decorator behaviourally rather than asserting on internals: empty response = 3 attempts, permanent BaseLLMError = 1.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 8ce942d addressed this comment by adding EmptyLLMResponseError to the agentic judge retry policy, matching one-shot behavior. Tests verify three retries for transient empty responses and no retries for permanent failures.

Comment on lines +217 to +220
except exceptions.BaseLLMError:
# Already classified (e.g. EmptyLLMResponseError); re-wrapping would
# erase the subclass callers retry on and double the message prefix.
raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Classified LLM failures lose provider diagnostics

generate_chat_completion and generate_provider_response let classified errors, including EmptyLLMResponseError, bypass LOGGER.error("Failed to call LLM provider...") because except BaseLLMError: raise re-raises them from get_provider_response’s yield scope, so callers lose provider-context diagnostics — should we log before re-raising while preserving the exception type?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/evaluation/models/base_model.py` around lines 217-220 and 258-259,
update `get_provider_response` and `aget_provider_response` so classified `BaseLLMError`
exceptions are logged with the same provider-context diagnostic as other failures before
being re-raised. Preserve the original exception type and avoid wrapping or duplicating
the existing generic-error behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed — this one was a genuine regression I introduced, and an ironic one: the Failed to call LLM provider, reason: ... line is exactly what made the original CI failure diagnosable, and my except BaseLLMError: raise silenced it.

Verified with a captured log handler: before the fix, an EmptyLLMResponseError logged nothing at all. Now logs before re-raising, so the diagnostic survives and the exception type is still preserved for the retry predicate. Added a test asserting the log record is emitted.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 8ce942d addressed this comment by logging classified BaseLLMError exceptions before re-raising them in both synchronous and asynchronous provider-response paths, preserving their original types.

…stics

Address review: the agentic judge has its own retry policy, so empty
responses retried in one-shot scoring but propagated immediately in
agentic scoring. Include EmptyLLMResponseError there too.

Re-raising classified errors also skipped the LOGGER.error call, so the
"Failed to call LLM provider" diagnostic vanished for exactly the failure
it was needed for. Log before re-raising, preserving the type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread sdks/python/src/opik/evaluation/suite_evaluators/agentic/judge.py
Comment thread sdks/python/src/opik/evaluation/models/base_model.py
The async branches were only exercised indirectly. Adds ascore coverage for
retry-then-succeed and no-retry-on-permanent-error, plus a direct
aget_provider_response test asserting the type survives and the diagnostic
is logged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +377 to +384
class FlakyAsyncModel:
async def agenerate_chat_completion(self, messages, response_format=None):
calls["n"] += 1
if calls["n"] == 1:
raise exceptions.EmptyLLMResponseError(
"LLM returned no content and no tool calls"
)
return {"role": "assistant", "content": valid}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry mutates judge request unnoticed

The retry test tracks only calls["n"], so a retry can change messages or response_format and still pass when the second response parses — should we record both arguments per invocation and assert they match across calls?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/tests/unit/evaluation/suite_evaluators/test_llm_judge.py` around lines
377-384, update `test_ascore__transient_empty_llm_response__retries_and_succeeds` so the
flaky model records the `messages` and `response_format` received on every invocation.
After scoring, assert that both arguments are identical between the first failed attempt
and the second successful retry, while preserving the existing retry and result
assertions.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Python SDK E2E Tests Results (Python 3.13)

296 tests   289 ✅  3m 35s ⏱️
  1 suites    7 💤
  1 files      0 ❌

Results for commit 685dda6.

♻️ This comment has been updated with latest results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Python SDK python Pull requests that update Python code 🟠 size/L tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants