Skip to content

High-throughput async inference: native aiohttp fast-path, scalable async loop, and request-routing keys#1502

Open
gwarmstrong wants to merge 5 commits into
mainfrom
georgea/openai-usage-and-cache-key
Open

High-throughput async inference: native aiohttp fast-path, scalable async loop, and request-routing keys#1502
gwarmstrong wants to merge 5 commits into
mainfrom
georgea/openai-usage-and-cache-key

Conversation

@gwarmstrong

@gwarmstrong gwarmstrong commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes ns generate scale cleanly to high concurrency (5k+ in-flight requests) against OpenAI-compatible endpoints. At high concurrency the old litellm/httpx dispatch was the GIL-bound bottleneck (~95% of a single core in the async loop, per py-spy); this branch moves the hot path onto a C-extension-backed aiohttp transport and removes the per-completion write/postprocess serialization in the generate loop. It also adds optional, provider-agnostic request-routing keys that a proxy/gateway may use (a plain endpoint ignores them).

The fast path is the default and is correct for current vLLM (reasoning field rename) and streaming.

What's included

Native AsyncOpenAI + aiohttp fast-path (model/base.py)

  • Routes non-streaming and streaming chat through a native AsyncOpenAI client using the DefaultAioHttpClient (aiohttp) transport, bypassing litellm's httpx dispatch.
  • Default-on for OpenAI-compatible providers, gated by a new SUPPORTS_NATIVE_OPENAI class attribute → enabled for openai/vllm/sglang; azure, gemini, megatron, and multimodal stay on litellm. Disable with NEMO_SKILLS_OPENAI_AIOHTTP=0.
  • Reasoning content is read from both .reasoning (newer vLLM) and .reasoning_content (older) — litellm normalized this for us, but the native path sees vLLM's raw field.
  • Transient socket-error retries with backoff; extended httpx client TTL patch to survive long high-concurrency runs.

Scalable async loop (inference/generate.py)

  • Bounded worker pool (memory O(in_flight), not O(total inputs)) with a rate-limited connection ramp (ramp_rate_per_sec, default 4000) to avoid overflowing the kernel accept queue on prime.
  • Dedicated batched writer task (flush per batch) instead of a per-completion fout lock.
  • Separate postprocess/eval processor pool (postprocess_workers, default 16) so a lane frees its slot the instant the HTTP response returns.
  • Installs uvloop when available (NEMO_SKILLS_DISABLE_UVLOOP=1 to opt out).

Request-routing keys (model/base.py) — optional, provider-agnostic; a plain endpoint ignores them:

  • X-Request-Id: a per-request id for idempotency/tracing, kept stable across the OpenAI SDK retry loop; distinct calls get distinct ids.
  • prompt_cache_key: the standard OpenAI/vLLM prefix-cache hint, opt-in via cache_key=. Requests sharing a value hint a shared prompt prefix. Kept separate from X-Request-Id (shared vs per-request).

Misc

  • Dumps the full response.usage dict + reasoning-token breakdown into the output.
  • Auto-passthrough of NEMO_SKILLS_* env vars to the inner python launched by the pipeline (pipeline/utils/cluster.py).
  • core/requirements.txt: openaiopenai[aiohttp] so the transport is actually installed (per CONTRIBUTING, inference deps live here).

Config / env knobs

Knob Default Purpose
NEMO_SKILLS_OPENAI_AIOHTTP 1 Native fast-path; 0 forces litellm
NEMO_SKILLS_OPENAI_AIOHTTP_LIMIT 65536 aiohttp max connections
NEMO_SKILLS_TRANSIENT_RETRIES 3 Socket-level retry budget
NEMO_SKILLS_DISABLE_UVLOOP unset Opt out of uvloop
max_concurrent_requests (cfg) 512 Script-level concurrency cap
ramp_rate_per_sec (cfg) 4000 Connection ramp on prime
postprocess_workers (cfg) 16 Postprocess/eval pool size

Testing

Unit (tests/test_prompt_cache_routing.py, tests/test_native_openai_path.py — 15 tests): routing-key idempotency/affinity decoupling; reasoning extraction from both field names; stream + non-stream parsing; empty-choices chunk guard; per-provider native eligibility; env-var default/opt-out.

Live parity (real model on an OpenAI-compatible endpoint, nvidia/nemotron-3-nano-30b-a3b):

  • native vs litellm (AIOHTTP=0), × non-stream × stream → identical final answer; reasoning_content captured on all four; native transport active only by default and off with AIOHTTP=0.
  • Streaming yields well-formed chunks (generation/reasoning_content/finish_reason).
  • Tool-calling stream via native path → finish_reason=tool_calls, reassembled get_weather({"city":"Paris"}) (the exact chunk shape ToolCallingWrapper._stream_single consumes).

Reviewer note: this touches the core inference loop — worth running the SLURM GPU CI (add the run GPU tests label).

Known non-blocking notes

  • Streaming num_generated_tokens is None on both native and litellm paths (unchanged; ToolCallingWrapper counts via tokenizer). I did not enable stream_options.include_usage, but added the empty-choices guard so it's safe to turn on later.
  • The native aiohttp session isn't explicitly closed at process exit (same as litellm's existing aclient_session); harmless since the process exits after a run.

Summary by CodeRabbit

  • New Features
    • Improved async generation pipeline with bounded in-flight concurrency, smoother ramp-up, and configurable background postprocessing.
    • Native OpenAI-compatible async fast-path for eligible providers, with per-call request id headers and optional prompt-cache routing.
    • Optional uvloop acceleration for async generation.
    • Automatic forwarding of NEMO_SKILLS_* environment variables into jobs (excluding SSH tunnel controls).
  • Bug Fixes
    • More resilient retries, streaming/response parsing (including reasoning + usage), and safer async output restoration.
    • Multimodal and structured-output requests reliably fall back when native mode isn’t appropriate.
  • Tests
    • Added regression and unit tests for native fast-path selection, routing keys, env forwarding, and async ordering integrity.
  • Chores
    • Updated the OpenAI dependency to include the aiohttp extra.

Scale `ns generate` cleanly to high concurrency (5k+ in-flight) against
OpenAI-compatible endpoints. At high concurrency the litellm/httpx dispatch
was the GIL-bound bottleneck (~95% of one core in the async loop); this moves
the hot path onto a C-extension-backed aiohttp transport and removes the
per-completion write/postprocess serialization in the generate loop.

Native AsyncOpenAI + aiohttp fast-path (model/base.py):
- Routes non-streaming AND streaming chat through a native AsyncOpenAI client
  using the DefaultAioHttpClient (aiohttp) transport, bypassing litellm's httpx
  dispatch. Default-on for OpenAI-compatible providers, gated by a new
  SUPPORTS_NATIVE_OPENAI class attribute (openai/vllm/sglang); azure, gemini,
  megatron, and multimodal stay on litellm. Disable with
  NEMO_SKILLS_OPENAI_AIOHTTP=0.
- Reasoning content is read from both .reasoning (newer vLLM) and
  .reasoning_content (older) -- litellm normalized this for us, but the native
  path sees vLLM's raw field.
- Transient socket-error retries with backoff; extended httpx client TTL patch
  to survive long high-concurrency runs.

Scalable async loop (inference/generate.py):
- Bounded worker pool (memory O(in_flight)) with a rate-limited connection ramp
  (ramp_rate_per_sec) to avoid overflowing the kernel accept queue on prime.
- Dedicated batched writer task instead of a per-completion fout lock.
- Separate postprocess/eval processor pool (postprocess_workers) so a lane
  frees its slot the instant the HTTP response returns.
- Installs uvloop when available (NEMO_SKILLS_DISABLE_UVLOOP=1 to opt out).

Request-routing keys (model/base.py):
- X-Request-Id: a per-request id for idempotency/tracing, kept stable across
  the OpenAI SDK retry loop; distinct calls get distinct ids.
- prompt_cache_key: the standard OpenAI/vLLM prefix-cache hint, opt-in via
  cache_key=. Requests sharing a value hint a shared prompt prefix. Kept
  separate from X-Request-Id (shared vs per-request). A plain endpoint ignores
  both.

Misc:
- Dumps the full response.usage dict + reasoning-token breakdown.
- Auto-passthrough of NEMO_SKILLS_* env vars to the inner python launched by
  the pipeline (pipeline/utils/cluster.py).
- core/requirements.txt: openai -> openai[aiohttp] so the transport is
  installed.

Tests: tests/test_prompt_cache_routing.py (routing-key idempotency/affinity
decoupling) and tests/test_native_openai_path.py (reasoning extraction from
both fields, stream/non-stream parsing, empty-choices guard, per-provider
native eligibility, env-var default/opt-out).

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds native OpenAI-compatible request handling and prompt-cache routing, expands async generation into a bounded pipeline, updates async ordering restoration, and forwards selected NEMO_SKILLS_ environment variables.

Changes

Native OpenAI fast-path and request handling

Layer / File(s) Summary
Capability flags and dependency setup
core/requirements.txt, nemo_skills/inference/model/openai.py, nemo_skills/inference/model/vllm.py, nemo_skills/inference/model/azure.py, nemo_skills/inference/model/vllm_multimodal.py
Updates the OpenAI dependency and sets provider capability flags for native OpenAI fast-path eligibility.
BaseModel request routing and parsing
nemo_skills/inference/model/base.py
Adds native client gating, concurrency limiting, routing-key injection, transient retries, reasoning extraction, usage serialization, and streaming chunk guards.
Prompt-cache routing tests
tests/test_prompt_cache_routing.py
Exercises _apply_routing_keys for cache-key insertion, request-id generation, reuse behavior, and header preservation.
Native path and reasoning tests
tests/test_native_openai_path.py
Covers reasoning extraction, native client selection, request filtering, streaming robustness, and structured-output fallback behavior.

Async generation pipeline and environment passthrough

Layer / File(s) Summary
uvloop and pipeline configuration
nemo_skills/inference/generate.py
Adds conditional uvloop installation, async pipeline tuning fields, and queue placeholders on GenerationTask.
Async pipeline execution and restore ordering
nemo_skills/inference/generate.py, tests/test_async_loop_integrity.py
Reworks datapoint generation into queued processing, bounded scheduling, batched writing, sentinel shutdown, and async-order restoration.
Environment passthrough for NEMO_SKILLS_ variables
nemo_skills/pipeline/utils/cluster.py, tests/test_pipeline_utils.py
Adds automatic forwarding of selected NEMO_SKILLS_ environment variables while excluding SSH tunnel controls, with matching test coverage.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested labels: run GPU tests

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main themes: native aiohttp fast-path, async loop scaling, and request-routing keys.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch georgea/openai-usage-and-cache-key

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 5

🧹 Nitpick comments (4)
nemo_skills/pipeline/utils/cluster.py (1)

218-227: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Blanket NEMO_SKILLS_* prefix passthrough may forward unintended vars.

Any environment variable prefixed NEMO_SKILLS_ in the launching shell is auto-forwarded to the remote job, not just the intended tuning knobs (NEMO_SKILLS_OPENAI_AIOHTTP, NEMO_SKILLS_DISABLE_UVLOOP, etc.). If the prefix is reused for local-only debug/test vars in the future (or by other tooling), they'd silently leak into launched jobs/containers. Consider an explicit allowlist of known tuning-knob names instead of a blanket prefix scan, to keep the passthrough surface intentional.

♻️ Example of a tighter allowlist approach
-    for name in os.environ:
-        if name.startswith("NEMO_SKILLS_") and name not in optional_env_vars_to_add:
-            optional_env_vars_to_add.add(name)
+    known_tuning_env_vars = {
+        "NEMO_SKILLS_OPENAI_AIOHTTP",
+        "NEMO_SKILLS_DISABLE_UVLOOP",
+        "NEMO_SKILLS_TRANSIENT_RETRIES",
+        # add other explicit knobs here
+    }
+    for name in known_tuning_env_vars:
+        if name in os.environ and name not in optional_env_vars_to_add:
+            optional_env_vars_to_add.add(name)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemo_skills/pipeline/utils/cluster.py` around lines 218 - 227, The
passthrough logic in the cluster environment setup is too broad because it
forwards every NEMO_SKILLS_* variable from os.environ into
optional_env_vars_to_add. Tighten this in the env-collection block by replacing
the prefix scan with an explicit allowlist of known tuning variables (for
example the existing NEMO_SKILLS_OPENAI_AIOHTTP, NEMO_SKILLS_DISABLE_UVLOOP, and
NEMO_SKILLS_TRANSIENT_RETRIES names), so only intended knobs are exported by the
launcher.
nemo_skills/inference/model/base.py (3)

213-254: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Broad except Exception around native client construction.

The try/except Exception swallows any error from AsyncOpenAI(...)/DefaultAioHttpClient(...) construction, not just the documented ImportError case. The repo's own guideline is explicit about not catching exceptions that aren't expected to normally be raised. This is mitigated somewhat by the informative LOG.warning, but it also silently degrades performance (falls back to the slower litellm/httpx path) for genuinely unexpected bugs (e.g. a typo in httpx.Limits kwargs) instead of surfacing them.

Consider narrowing to except (ImportError, TypeError, ValueError) as e: (the realistic set for "aiohttp extra missing/partially installed") so truly unexpected errors still surface.

As per coding guidelines, "Don't catch exceptions when we don't expect them to be normally raised... let the code fail when something unexpected happens." Also flagged by static analysis (Ruff BLE001, "Do not catch blind exception: Exception").

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemo_skills/inference/model/base.py` around lines 213 - 254, The native
AsyncOpenAI client setup in base.py is catching a broad Exception, which hides
unexpected construction bugs and silently falls back to litellm/httpx. Narrow
the handler around the AsyncOpenAI/DefaultAioHttpClient block in the
native_enabled and self.SUPPORTS_NATIVE_OPENAI path to only the expected
failures for missing or partially installed aiohttp support, keeping the
existing LOG.warning and fallback behavior for those cases while letting other
errors surface.

Sources: Coding guidelines, Linters/SAST tools


107-117: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

concurrent_requests_limit default is frozen at import time, not read per-instantiation.

int(os.environ.get("NEMO_SKILLS_MODEL_CONCURRENCY", "65536")) as a parameter default is evaluated once, when this def __init__ line is executed at module import — not each time a model is constructed. Unlike NEMO_SKILLS_SSH_SERVER/NEMO_SKILLS_SSH_KEY_PATH, which are read via os.getenv() inside the method body, any change to this env var after the module is first imported (e.g. in-process config, tests using monkeypatch.setenv) won't be picked up. Typically harmless for a fresh process, but inconsistent with the stated "override via env var" contract in the comment.

♻️ Proposed fix: read the env var lazily like the other overrides
-        concurrent_requests_limit: int = int(os.environ.get("NEMO_SKILLS_MODEL_CONCURRENCY", "65536")),
+        concurrent_requests_limit: int | None = None,
     ):
+        if concurrent_requests_limit is None:
+            concurrent_requests_limit = int(os.environ.get("NEMO_SKILLS_MODEL_CONCURRENCY", "65536"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemo_skills/inference/model/base.py` around lines 107 - 117, The default for
concurrent_requests_limit in Base.__init__ is being evaluated at module import
time, so later changes to NEMO_SKILLS_MODEL_CONCURRENCY are ignored. Move the
environment lookup into the __init__ body and assign the default lazily,
matching the pattern already used for the other env-based overrides in
Base.__init__. Keep the existing fallback value and preserve the current
user-facing behavior, but ensure each model construction reads the latest env
var value.

604-625: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Triple-nested fallback for serializing usage adds complexity for a rarely-hit corner case.

model_dump()dict() → manual field construction is three levels of defensive fallback (with a bare except Exception at the innermost level, flagged by Ruff BLE001) to build one dict. Given essentially all supported providers here return pydantic usage objects with model_dump(), the two fallback layers mostly guard against an untested/unexpected shape — which per the repo's guideline should probably just fail loudly rather than degrade to a partial dict.

Consider narrowing the fallback to a single, more specific except (AttributeError, TypeError): and dropping the innermost blind except Exception, or removing the fallback chain entirely if none of the supported providers realistically hit it.

As per coding guidelines, "Don't catch exceptions when we don't expect them to be normally raised... it's much better to just [fail] with a clear error," and "Keep code simple and elegant... minimize conditional checks." Also flagged by static analysis (Ruff BLE001 at line 620).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemo_skills/inference/model/base.py` around lines 604 - 625, The `usage`
serialization in `BaseModel` is over-defensive and includes a broad inner
exception handler that Ruff flags. Simplify the fallback path around
`response.usage` in the `model_dump()` block: keep the primary `model_dump()`
handling, and either remove the extra fallback layers entirely or collapse them
into a single specific exception catch such as `AttributeError`/`TypeError`.
Avoid the bare `except Exception` and the manual partial-dict fallback so
unexpected `usage` shapes fail clearly instead of silently degrading.

Sources: Coding guidelines, Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@nemo_skills/inference/generate.py`:
- Around line 1013-1020: Stop swallowing datapoint processing failures in the
async output path: the exception handling around postprocess/evaluate in
generate.py currently logs and continues, which can enqueue incomplete results
and break restore_async_order() later. Update the async draining logic in the
relevant handler(s) around postprocess_single_output, evaluate_single_datapoint,
and the similar block near the other referenced section to collect any failure
state while draining the queue, keep processing until the pipeline is drained,
and then raise the captured error before restoring/removing the async file. Also
use direct access for expected keys like _async_position instead of .get where
the position must exist.
- Line 1001: The postprocess worker count is being silently clamped in
generate.py, which hides invalid user input. Update the logic around
self.cfg.postprocess_workers in the generation path to validate the configured
value explicitly and raise a ValueError when it is 0 or negative instead of
forcing num_processors to 1. Keep the check close to the num_processors
assignment so the failure is immediate and easy to trace.
- Around line 1037-1044: The async writer path in the output loop bypasses the
`dump_outputs()` hook, so custom task subclasses lose their overridden output
handling. Update the writer logic in `generate.py` to route each record through
`dump_outputs()` the same way the fallback and prefilled paths do, rather than
writing `json.dumps(...)` directly. Keep the fix localized around the async
batch flush/write section so the `dump_outputs()` behavior is preserved for all
generated records.

In `@nemo_skills/inference/model/base.py`:
- Around line 213-254: The AsyncOpenAI aiohttp client created in BaseModelModel
initialization is never closed, which can leak aiohttp sessions on repeated
model construction. Update the model shutdown path in base.py so the existing
cleanup logic also awaits self._async_openai_client.close() when that client was
created, and keep the current _tunnel cleanup intact. Use the existing
_async_openai_client field and the destructor/teardown path as the place to add
the async close handling, with a safe fallback if the client was never
initialized.
- Around line 327-347: The native OpenAI request path is passing
prompt_cache_key directly in request_params, which breaks older openai installs;
update the OpenAI-specific call site to route cache_key through extra_body
instead, or enforce a minimum openai version of 1.98.0 in requirements. Use
_apply_routing_keys to keep X-Request-Id behavior unchanged, and make the later
AsyncOpenAI.chat.completions.create path follow the same compatibility handling
so cache_key never hits unsupported SDK versions.

---

Nitpick comments:
In `@nemo_skills/inference/model/base.py`:
- Around line 213-254: The native AsyncOpenAI client setup in base.py is
catching a broad Exception, which hides unexpected construction bugs and
silently falls back to litellm/httpx. Narrow the handler around the
AsyncOpenAI/DefaultAioHttpClient block in the native_enabled and
self.SUPPORTS_NATIVE_OPENAI path to only the expected failures for missing or
partially installed aiohttp support, keeping the existing LOG.warning and
fallback behavior for those cases while letting other errors surface.
- Around line 107-117: The default for concurrent_requests_limit in
Base.__init__ is being evaluated at module import time, so later changes to
NEMO_SKILLS_MODEL_CONCURRENCY are ignored. Move the environment lookup into the
__init__ body and assign the default lazily, matching the pattern already used
for the other env-based overrides in Base.__init__. Keep the existing fallback
value and preserve the current user-facing behavior, but ensure each model
construction reads the latest env var value.
- Around line 604-625: The `usage` serialization in `BaseModel` is
over-defensive and includes a broad inner exception handler that Ruff flags.
Simplify the fallback path around `response.usage` in the `model_dump()` block:
keep the primary `model_dump()` handling, and either remove the extra fallback
layers entirely or collapse them into a single specific exception catch such as
`AttributeError`/`TypeError`. Avoid the bare `except Exception` and the manual
partial-dict fallback so unexpected `usage` shapes fail clearly instead of
silently degrading.

In `@nemo_skills/pipeline/utils/cluster.py`:
- Around line 218-227: The passthrough logic in the cluster environment setup is
too broad because it forwards every NEMO_SKILLS_* variable from os.environ into
optional_env_vars_to_add. Tighten this in the env-collection block by replacing
the prefix scan with an explicit allowlist of known tuning variables (for
example the existing NEMO_SKILLS_OPENAI_AIOHTTP, NEMO_SKILLS_DISABLE_UVLOOP, and
NEMO_SKILLS_TRANSIENT_RETRIES names), so only intended knobs are exported by the
launcher.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: bdb60ce6-6f03-4464-8a0d-e4ad125afb39

📥 Commits

Reviewing files that changed from the base of the PR and between e5d40c5 and 78c6e29.

📒 Files selected for processing (10)
  • core/requirements.txt
  • nemo_skills/inference/generate.py
  • nemo_skills/inference/model/azure.py
  • nemo_skills/inference/model/base.py
  • nemo_skills/inference/model/openai.py
  • nemo_skills/inference/model/vllm.py
  • nemo_skills/inference/model/vllm_multimodal.py
  • nemo_skills/pipeline/utils/cluster.py
  • tests/test_native_openai_path.py
  • tests/test_prompt_cache_routing.py

queue_cap = max(self.cfg.max_concurrent_requests * 2, 1024)
self._raw_queue: asyncio.Queue = asyncio.Queue(maxsize=queue_cap)
self._output_queue = asyncio.Queue(maxsize=queue_cap)
num_processors = max(1, int(self.cfg.postprocess_workers))

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject invalid postprocess_workers instead of silently clamping.

Line 1001 turns 0/negative user input into 1, so a bad config silently runs with different behavior than requested. Prefer an explicit ValueError.

Proposed fix
-            num_processors = max(1, int(self.cfg.postprocess_workers))
+            num_processors = int(self.cfg.postprocess_workers)
+            if num_processors < 1:
+                raise ValueError(f"postprocess_workers must be >= 1, got {self.cfg.postprocess_workers}")

As per coding guidelines, “Avoid cases when user-passed parameters are unused” and “the code should fail” for unsupported arguments.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
num_processors = max(1, int(self.cfg.postprocess_workers))
num_processors = int(self.cfg.postprocess_workers)
if num_processors < 1:
raise ValueError(f"postprocess_workers must be >= 1, got {self.cfg.postprocess_workers}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemo_skills/inference/generate.py` at line 1001, The postprocess worker count
is being silently clamped in generate.py, which hides invalid user input. Update
the logic around self.cfg.postprocess_workers in the generation path to validate
the configured value explicitly and raise a ValueError when it is 0 or negative
instead of forcing num_processors to 1. Keep the check close to the
num_processors assignment so the failure is immediate and easy to trace.

Source: Coding guidelines

Comment on lines +1013 to +1020
try:
await self.postprocess_single_output(output, data_point)
if self.should_run_evaluation and self.evaluator:
output = await self.evaluate_single_datapoint({**data_point, **output})
except Exception:
pos = data_point.get(self.cfg.async_position_key, "?")
LOG.exception("postprocess/evaluate failed for async_pos=%s", pos)
await self._output_queue.put((output, data_point))

@coderabbitai coderabbitai Bot Jul 1, 2026

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don’t swallow datapoint failures; raise after draining the pipeline.

These handlers log and continue. A postprocess/eval failure can enqueue raw output without _async_position; a generation failure can skip a position entirely. restore_async_order() then fails late or writes incomplete results. Collect failures while draining queues, then raise before restoring/removing the async file.

Proposed pattern
+            errors = []
+
             async def _processor():
@@
-                    try:
+                    try:
                         await self.postprocess_single_output(output, data_point)
                         if self.should_run_evaluation and self.evaluator:
                             output = await self.evaluate_single_datapoint({**data_point, **output})
-                    except Exception:
-                        pos = data_point.get(self.cfg.async_position_key, "?")
+                    except Exception as exc:
+                        errors.append(exc)
+                        pos = data_point[self.cfg.async_position_key]
                         LOG.exception("postprocess/evaluate failed for async_pos=%s", pos)
+                        continue
                     await self._output_queue.put((output, data_point))
@@
             async def _safe_one(dp):
                 try:
                     await self._generate_and_save_datapoint(dp, data, fout, pbar)
-                except Exception:
-                    pos = dp.get(self.cfg.async_position_key, "?")
+                except Exception as exc:
+                    errors.append(exc)
+                    pos = dp[self.cfg.async_position_key]
                     LOG.exception("datapoint async_pos=%s crashed", pos)
@@
             await self._output_queue.put(None)
             await writer_task
+            if errors:
+                raise RuntimeError(f"{len(errors)} datapoint(s) failed during async generation")
             self._raw_queue = None
             self._output_queue = None

As per coding guidelines, “Don’t catch exceptions when we don’t expect them to be normally raised” and prefer direct key access when keys are expected.

Also applies to: 1072-1077

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemo_skills/inference/generate.py` around lines 1013 - 1020, Stop swallowing
datapoint processing failures in the async output path: the exception handling
around postprocess/evaluate in generate.py currently logs and continues, which
can enqueue incomplete results and break restore_async_order() later. Update the
async draining logic in the relevant handler(s) around
postprocess_single_output, evaluate_single_datapoint, and the similar block near
the other referenced section to collect any failure state while draining the
queue, keep processing until the pipeline is drained, and then raise the
captured error before restoring/removing the async file. Also use direct access
for expected keys like _async_position instead of .get where the position must
exist.

Source: Coding guidelines

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gwarmstrong not sure if this is correct but if it is, we should probably fail on exceptions

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.

@Kipok, agreed — this is a real gap. Right now _processor() and _safe_one() both catch broad Exception, log it, and keep going:

  • A postprocess/eval failure in _processor() logs and then still does await self._output_queue.put((output, data_point)) with whatever partial output exists — if postprocess_single_output failed before setting _async_position-related fields or before merging original_data_point, that record can be malformed or missing the position key entirely.
  • A crash in _generate_and_save_datapoint inside _safe_one() is caught, logged, and silently dropped — no placeholder is emitted for that position at all, so that datapoint's slot is simply missing from the async file.

Both cases mean restore_async_order() either writes an incomplete/corrupted output file or (per the current implementation) silently drops that position with just a warning log — so a large async run could finish "successfully" while quietly missing results, which is easy to miss without inspecting logs closely.

This does match the coding guideline of not swallowing unexpected exceptions — the fix should be to track these failures while draining the pipeline (or emit clearly-marked placeholder records with the position preserved) and then raise once the queues are fully drained, rather than letting the run report success with silently missing/malformed data. Happy to help draft that once you and @gwarmstrong agree on the exact approach (hard fail vs. always-emit-placeholder-then-fail).

Comment thread nemo_skills/inference/generate.py Outdated
Comment on lines +213 to +254
if native_enabled and self.SUPPORTS_NATIVE_OPENAI:
try:
from openai import AsyncOpenAI, DefaultAioHttpClient

# openai SDK defaults the aiohttp connector to
# max_connections=1000, max_keepalive_connections=100,
# which silently caps the lane count at high
# concurrency (8k lanes see only ~1000 open TCP
# connections; the rest stall in the aiohttp connector
# queue). Also, httpx-aiohttp's transport translates
# httpx.Limits(max_connections=None) to "omit the
# `limit` kwarg," which makes aiohttp fall back to ITS
# default of 100 -- worse than the openai SDK
# default. We therefore pass an explicit large
# integer (NEMO_SKILLS_OPENAI_AIOHTTP_LIMIT, default
# 65536) instead of None.
aiohttp_limit = int(os.environ.get("NEMO_SKILLS_OPENAI_AIOHTTP_LIMIT", "65536"))
self._async_openai_client = AsyncOpenAI(
api_key=api_key,
base_url=self.base_url,
http_client=DefaultAioHttpClient(
limits=httpx.Limits(
max_connections=aiohttp_limit,
max_keepalive_connections=aiohttp_limit,
),
),
max_retries=0, # we own retries; SDK's retry would
# rebuild the request and double-count
# concurrent_semaphore holds.
)
except Exception as e:
# ImportError if openai[aiohttp] isn't installed, but the
# DefaultAioHttpClient constructor can also raise other errors
# when the aiohttp extra is only partially present. Degrade to
# litellm/httpx rather than failing model construction.
LOG.warning(
"Native AsyncOpenAI aiohttp fast-path unavailable (%s: %s); "
"falling back to litellm/httpx. Install openai[aiohttp] to enable it.",
type(e).__name__,
e,
)
self._async_openai_client = None

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for any explicit close/aclose handling of the native client elsewhere in the codebase.
rg -n -A3 -B3 '_async_openai_client' nemo_skills/inference

Repository: NVIDIA-NeMo/Skills

Length of output: 3445


🏁 Script executed:

rg -n -A2 -B2 'aclose\(|close\(|__del__|async with AsyncOpenAI|DefaultAioHttpClient' nemo_skills

Repository: NVIDIA-NeMo/Skills

Length of output: 15419


🏁 Script executed:

sed -n '260,330p' nemo_skills/inference/model/base.py

Repository: NVIDIA-NeMo/Skills

Length of output: 3148


🌐 Web query:

openai python AsyncOpenAI DefaultAioHttpClient aclose close documentation

💡 Result:

In the OpenAI Python SDK, the recommended way to manage the lifecycle of an AsyncOpenAI client is through an asynchronous context manager, which automatically handles the closing of underlying HTTP connections [1][2][3]. ### Closing the Client While the SDK may automatically close connections when the client is garbage collected, relying on this is not recommended for production applications [1][3]. Instead, you should use one of the following methods: 1. Asynchronous Context Manager (Recommended) This is the cleanest approach and ensures the underlying http_client is closed correctly when the block exits [2][3]. python async with AsyncOpenAI() as client: # perform your operations pass # client is automatically closed here 2. Explicit await client.close() If you cannot use a context manager (e.g., in a long-running service where the client is stored in application state), you must manually call await client.close() during application shutdown [2][3]. python # During application startup client = AsyncOpenAI() # During application shutdown await client.close() ### DefaultAioHttpClient The DefaultAioHttpClient is a utility provided by the SDK to use aiohttp as the underlying HTTP backend instead of the default httpx client [1][4]. It is used when instantiating AsyncOpenAI: python from openai import AsyncOpenAI, DefaultAioHttpClient async with AsyncOpenAI(http_client=DefaultAioHttpClient()) as client: # perform operations pass Regardless of whether you use the default httpx transport or DefaultAioHttpClient, the AsyncOpenAI client exposes an await client.close() method that shuts down the underlying HTTP client [5][4]. Note: Avoid using aclose() directly on client instances; the SDK exposes the standard close() method, which is an async method (making it awaitable) that internally calls the appropriate closure method on the underlying transport [5][4]. You may encounter aclose() in the context of specific stream objects or underlying library components, but for the top-level AsyncOpenAI client, await client.close() is the official public API [5][2][6].

Citations:


Close the native OpenAI client on shutdown.
self._async_openai_client wraps an aiohttp-backed AsyncOpenAI, but this class never calls await client.close() anywhere. __del__ only stops _tunnel, so repeated model construction in one process can leave aiohttp sessions/connectors open.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 243-243: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemo_skills/inference/model/base.py` around lines 213 - 254, The AsyncOpenAI
aiohttp client created in BaseModelModel initialization is never closed, which
can leak aiohttp sessions on repeated model construction. Update the model
shutdown path in base.py so the existing cleanup logic also awaits
self._async_openai_client.close() when that client was created, and keep the
current _tunnel cleanup intact. Use the existing _async_openai_client field and
the destructor/teardown path as the place to add the async close handling, with
a safe fallback if the client was never initialized.

Comment on lines +327 to +347
@staticmethod
def _apply_routing_keys(request_params: dict, cache_key: str | None) -> None:
"""Attach optional, provider-agnostic request-routing keys. Plain
OpenAI/vLLM/gemini endpoints ignore (or echo) them.

* `X-Request-Id` (extra_headers) = per-request id for idempotency /
tracing. Kept stable across the OpenAI SDK's internal retry loop so
a retry is identifiable as the same logical call; distinct calls
(incl. parallel samples) get distinct ids.
* `prompt_cache_key` (opt-in via `cache_key`) = the standard
OpenAI/vLLM prefix-cache hint. Requests that share a value are
hinting they share a common prompt prefix and can reuse a warm
prefix cache. Omitted when cache_key is None.

Kept separate on purpose: prompt_cache_key is meant to be SHARED across
distinct requests, so it must not double as the per-request idempotency id.
"""
request_params.setdefault("extra_headers", {}).setdefault("X-Request-Id", str(uuid.uuid4()))
if cache_key is not None:
request_params["prompt_cache_key"] = cache_key

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does openai-python chat.completions.create() accept prompt_cache_key as a typed top-level parameter, and since which version?

💡 Result:

Yes, the openai-python library's chat.completions.create method now accepts prompt_cache_key as a typed top-level parameter [1][2]. This parameter was added to the library in version 1.98.0 [3]. Prior to this version, developers attempting to use prompt_cache_key as a parameter would encounter a TypeError, and it was recommended to use the extra_body parameter as a workaround [4]. The prompt_cache_key parameter is used to influence routing and improve cache hit rates by allowing you to associate requests with specific cache identifiers, effectively replacing the previously used user field for this purpose [1][5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== openai dependency pins ==\n'
git ls-files | rg '(^|/)(requirements\.txt|pyproject\.toml|uv\.lock|poetry\.lock|Pipfile|Pipfile\.lock)$|core/requirements\.txt|requirements/pipeline\.txt'

printf '\n== relevant openai constraints ==\n'
rg -n --hidden --no-heading 'openai|litellm|prompt_cache_key|extra_body|allowed_openai_params|SUPPORTS_NATIVE_OPENAI' \
  core nemo_skills . 2>/dev/null | sed -n '1,220p'

printf '\n== target file excerpt ==\n'
sed -n '300,480p' nemo_skills/inference/model/base.py

Repository: NVIDIA-NeMo/Skills

Length of output: 33390


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== core/requirements.txt ==\n'
sed -n '1,80p' core/requirements.txt

printf '\n== pyproject openai/litellm constraints ==\n'
sed -n '1,140p' pyproject.toml

printf '\n== nemo_skills/inference/model/openai.py ==\n'
sed -n '1,240p' nemo_skills/inference/model/openai.py

Repository: NVIDIA-NeMo/Skills

Length of output: 12682


Route prompt_cache_key through extra_body on the native OpenAI path, or pin openai>=1.98.0. core/requirements.txt doesn’t set a minimum openai version, so older installs will raise TypeError when cache_key is used with the native AsyncOpenAI.chat.completions.create() call. Same issue applies to the later native OpenAI call site.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemo_skills/inference/model/base.py` around lines 327 - 347, The native
OpenAI request path is passing prompt_cache_key directly in request_params,
which breaks older openai installs; update the OpenAI-specific call site to
route cache_key through extra_body instead, or enforce a minimum openai version
of 1.98.0 in requirements. Use _apply_routing_keys to keep X-Request-Id behavior
unchanged, and make the later AsyncOpenAI.chat.completions.create path follow
the same compatibility handling so cache_key never hits unsupported SDK
versions.

…indings

Follow-up fixes from code review of the high-throughput async path.

async_loop data integrity (generate.py):
- On a datapoint that fails generation, emit a placeholder record (routed
  through the postprocess queue so it is position-stamped) instead of dropping
  it silently. A dropped position made restore_async_order either IndexError at
  finalization (a non-last position missing) or silently drop/misalign rows.
- On a postprocess/eval failure, stamp async_position (and a postprocess_error
  marker) onto the record so it stays placeable even if postprocess raised
  before merging the original datapoint.
- restore_async_order sizes by max(position)+1 and tolerates a missing position
  (pop-with-default + skip) instead of assuming exactly-one-line-per-position.
- The writer flushes through self.dump_outputs (the overridable hook the
  prefilled path uses) via a single helper, so subclass serialization applies
  to async records and the duplicated write path is gone.

Native fast-path (model/base.py):
- Give the native AsyncOpenAI client the same max_retries as the litellm path
  (was 0). The SDK retries HTTP 429/5xx (which our transient-error except does
  not catch); leaving it at 0 dropped those under load on the default path.
- Drop both litellm-only keys (named _LITELLM_ONLY_CHAT_PARAMS) and None-valued
  params from the native request body: litellm omits None fields, but the SDK
  serializes an explicit None as JSON null, which some servers reject.

Env passthrough (pipeline/utils/cluster.py):
- Keep forwarding the NEMO_SKILLS_ prefix, but skip the SSH-tunnel vars
  (NEMO_SKILLS_SSH_SERVER / NEMO_SKILLS_SSH_KEY_PATH): forwarding a launcher's
  tunnel config into a co-located cluster job makes model init open a tunnel it
  shouldn't and fail on a host key path that doesn't exist in the container.

Tests: tests/test_async_loop_integrity.py (reorder tolerance), plus native
None/litellm-only param stripping and the env-passthrough allow/deny behavior.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@nemo_skills/inference/generate.py`:
- Around line 1176-1184: The reorder logic in generate.py is dropping async
records that are missing self.cfg.async_position_key, which causes silent data
loss during finalization. Update the reordering path around ordered_generations
and the gen_dict.pop(key, None) handling so positionless records are preserved
instead of skipped, and make the failure explicit if the key is truly required.
Keep the recovery flow intact in the finalization path and ensure
generate/reorder does not delete the only recoverable data when a record lacks
the async position key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: d73f83ba-187f-4a2e-971a-3b70bca598d2

📥 Commits

Reviewing files that changed from the base of the PR and between 78c6e29 and c4de6d3.

📒 Files selected for processing (6)
  • nemo_skills/inference/generate.py
  • nemo_skills/inference/model/base.py
  • nemo_skills/pipeline/utils/cluster.py
  • tests/test_async_loop_integrity.py
  • tests/test_native_openai_path.py
  • tests/test_pipeline_utils.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • nemo_skills/pipeline/utils/cluster.py
  • nemo_skills/inference/model/base.py

Comment on lines +1176 to 1184
key = self.cfg.async_position_key
positions = [gen[key] for gen in generations if key in gen]
ordered_generations = [None] * ((max(positions) + 1) if positions else 0)
for gen_dict in generations:
async_pos = gen_dict.pop(self.cfg.async_position_key)
async_pos = gen_dict.pop(key, None)
if async_pos is None:
LOG.warning("async record missing %s, dropping from reorder", key)
continue
ordered_generations[async_pos] = gen_dict

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't drop positionless async records during finalization.

If a record reaches reorder without self.cfg.async_position_key, this branch logs a warning, omits the row from the final JSONL, and then deletes the only recovery file. That is silent data loss.

Proposed fix
         for gen_dict in generations:
-            async_pos = gen_dict.pop(key, None)
-            if async_pos is None:
-                LOG.warning("async record missing %s, dropping from reorder", key)
-                continue
+            if key not in gen_dict:
+                raise ValueError(f"async record missing required key {key!r} during reorder")
+            async_pos = gen_dict.pop(key)
             ordered_generations[async_pos] = gen_dict

As per coding guidelines, “Don't use .get() for accessing dictionary keys if the code expects them to be present” and “Errors should never pass silently.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
key = self.cfg.async_position_key
positions = [gen[key] for gen in generations if key in gen]
ordered_generations = [None] * ((max(positions) + 1) if positions else 0)
for gen_dict in generations:
async_pos = gen_dict.pop(self.cfg.async_position_key)
async_pos = gen_dict.pop(key, None)
if async_pos is None:
LOG.warning("async record missing %s, dropping from reorder", key)
continue
ordered_generations[async_pos] = gen_dict
key = self.cfg.async_position_key
positions = [gen[key] for gen in generations if key in gen]
ordered_generations = [None] * ((max(positions) + 1) if positions else 0)
for gen_dict in generations:
if key not in gen_dict:
raise ValueError(f"async record missing required key {key!r} during reorder")
async_pos = gen_dict.pop(key)
ordered_generations[async_pos] = gen_dict
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemo_skills/inference/generate.py` around lines 1176 - 1184, The reorder
logic in generate.py is dropping async records that are missing
self.cfg.async_position_key, which causes silent data loss during finalization.
Update the reordering path around ordered_generations and the gen_dict.pop(key,
None) handling so positionless records are preserved instead of skipped, and
make the failure explicit if the key is truly required. Keep the recovery flow
intact in the finalization path and ensure generate/reorder does not delete the
only recoverable data when a record lacks the async position key.

Source: Coding guidelines

… normalized

- Remove the redundant "kept separate on purpose" sentence from the
  _apply_routing_keys docstring (the X-Request-Id / prompt_cache_key bullets
  already cover it).
- Lead the _extract_reasoning docstring with why the normalization is manual:
  litellm normally maps vLLM's reasoning field to reasoning_content, but the
  native AsyncOpenAI fast-path bypasses litellm and sees the raw field.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
…e path

CI failure: tests/test_generation.py::test_judge_generations_with_structured_output
produced an empty judgement -> JSONDecodeError. Root cause: the judge uses
++structured_output (a pydantic BaseModel response_format) on server_type=openai,
which now defaults to the native AsyncOpenAI path. The SDK's
chat.completions.create() rejects a BaseModel class:

  TypeError: You tried to pass a `BaseModel` class to chat.completions.create();
  You must use chat.completions.parse() instead

litellm accepts a BaseModel and converts it to a JSON schema, so route such
requests back through litellm: when response_format is a pydantic BaseModel
subclass, skip the native fast-path. dict/json_schema response_formats stay on
the native path. (pydantic import is aliased -- this module defines its own
class named BaseModel.)

Verified live against an OpenAI-compatible endpoint: native default + pydantic
response_format now returns valid structured JSON. Adds a unit test asserting
the fallback.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/test_pipeline_utils.py (1)

479-487: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert forwarded values, not just key presence.

This test would still pass if get_env_variables() forwarded the right names with wrong values. Use direct key access and compare the expected env values.

Proposed fix
-    for forwarded in (
-        "NEMO_SKILLS_OPENAI_AIOHTTP",
-        "NEMO_SKILLS_DISABLE_UVLOOP",
-        "NEMO_SKILLS_OPENAI_BASE_URL",
-        "NEMO_SKILLS_SANDBOX_HOST",
-        "NEMO_SKILLS_CONFIG_DIR",
-        "NEMO_SKILLS_SOME_FUTURE_KNOB",
-    ):
-        assert forwarded in env, f"{forwarded} should be forwarded"
+    expected_forwarded = {
+        "NEMO_SKILLS_OPENAI_AIOHTTP": "1",
+        "NEMO_SKILLS_DISABLE_UVLOOP": "1",
+        "NEMO_SKILLS_OPENAI_BASE_URL": "http://model:8000/v1",
+        "NEMO_SKILLS_SANDBOX_HOST": "sandbox",
+        "NEMO_SKILLS_CONFIG_DIR": "/home/user/cluster_configs",
+        "NEMO_SKILLS_SOME_FUTURE_KNOB": "x",
+    }
+    for forwarded, value in expected_forwarded.items():
+        assert env[forwarded] == value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_pipeline_utils.py` around lines 479 - 487, The test only checks
that get_env_variables() forwards the expected keys, so it can still pass with
incorrect values. Update the assertions in test_pipeline_utils.py to read the
forwarded values directly from env and compare them against the expected source
env values for each symbol, using the existing get_env_variables() result and
the listed NEMO_SKILLS_* keys.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@nemo_skills/inference/generate.py`:
- Around line 992-996: The ramp scheduling in generate.py ignores very small
ramp_rate_per_sec values because ramp_batch is forced to at least 50 in the ramp
logic. Update the ramp calculation around ramp_rate, ramp_batch, and
ramp_interval so user-provided low rates are honored exactly, without a minimum
burst that exceeds the configured limit. Keep the behavior in the same ramp
control block and adjust the batching formula to derive batch size directly from
ramp_rate_per_sec while preserving the smooth quarter-second pacing intent.

---

Nitpick comments:
In `@tests/test_pipeline_utils.py`:
- Around line 479-487: The test only checks that get_env_variables() forwards
the expected keys, so it can still pass with incorrect values. Update the
assertions in test_pipeline_utils.py to read the forwarded values directly from
env and compare them against the expected source env values for each symbol,
using the existing get_env_variables() result and the listed NEMO_SKILLS_* keys.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 3009abb2-0558-4723-824f-8d00c901a929

📥 Commits

Reviewing files that changed from the base of the PR and between de62c92 and 3665bb3.

📒 Files selected for processing (10)
  • nemo_skills/inference/generate.py
  • nemo_skills/inference/model/azure.py
  • nemo_skills/inference/model/base.py
  • nemo_skills/inference/model/openai.py
  • nemo_skills/inference/model/vllm_multimodal.py
  • nemo_skills/pipeline/utils/cluster.py
  • tests/test_async_loop_integrity.py
  • tests/test_native_openai_path.py
  • tests/test_pipeline_utils.py
  • tests/test_prompt_cache_routing.py
💤 Files with no reviewable changes (1)
  • nemo_skills/inference/model/openai.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • nemo_skills/inference/model/vllm_multimodal.py
  • nemo_skills/inference/model/azure.py
  • tests/test_prompt_cache_routing.py
  • nemo_skills/pipeline/utils/cluster.py
  • tests/test_async_loop_integrity.py
  • tests/test_native_openai_path.py
  • nemo_skills/inference/model/base.py

Comment on lines +992 to +996
ramp_rate = self.cfg.ramp_rate_per_sec
if ramp_rate > 0:
# Quarter-second batches keep the ramp smooth while filling the pool promptly.
ramp_batch = max(50, ramp_rate // 4)
ramp_interval = ramp_batch / ramp_rate

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Honor low ramp_rate_per_sec values.

With ramp_rate_per_sec=1, Line 995 still creates an initial burst of 50 tasks before sleeping, which violates the configured ramp limit and can overload endpoints the knob is meant to protect.

Proposed fix
-                ramp_batch = max(50, ramp_rate // 4)
+                ramp_batch = max(1, ramp_rate // 4)
                 ramp_interval = ramp_batch / ramp_rate

As per coding guidelines, user-passed parameters should not be silently ignored or changed.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ramp_rate = self.cfg.ramp_rate_per_sec
if ramp_rate > 0:
# Quarter-second batches keep the ramp smooth while filling the pool promptly.
ramp_batch = max(50, ramp_rate // 4)
ramp_interval = ramp_batch / ramp_rate
ramp_rate = self.cfg.ramp_rate_per_sec
if ramp_rate > 0:
# Quarter-second batches keep the ramp smooth while filling the pool promptly.
ramp_batch = max(1, ramp_rate // 4)
ramp_interval = ramp_batch / ramp_rate
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemo_skills/inference/generate.py` around lines 992 - 996, The ramp
scheduling in generate.py ignores very small ramp_rate_per_sec values because
ramp_batch is forced to at least 50 in the ramp logic. Update the ramp
calculation around ramp_rate, ramp_batch, and ramp_interval so user-provided low
rates are honored exactly, without a minimum burst that exceeds the configured
limit. Keep the behavior in the same ramp control block and adjust the batching
formula to derive batch size directly from ramp_rate_per_sec while preserving
the smooth quarter-second pacing intent.

Source: Coding guidelines

# TODO: remove this after we no longer use gpt-oss or it's fixed in vllm
# Keep the targeted bad-request retry budget separate from retries for
# transient connection failures.
max_retries = 2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

could you clean this up please - I think current retry count is never incremented and while loop is basically while True and there is a separate counter for transient retries. Should either have explicity while True and remove these outdated vars or just use retry_count without adding transient_count like it was before

# converts it to a JSON schema. (dict/json_schema
# response_formats are fine on the native path.)
rf = request_params.get("response_format")
needs_litellm_response_format = inspect.isclass(rf) and issubclass(rf, PydanticBaseModel)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this looks a bit fragile as a logic for routing to litellm, but I guess we will retire all this code soon anyway, so hopefully ok for now. And gym doesn't use litellm at all

# Set NEMO_SKILLS_DISABLE_UVLOOP to use the default asyncio event loop.
if not os.environ.get("NEMO_SKILLS_DISABLE_UVLOOP"):
try:
import uvloop

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does this need to be added to requirements?

Comment on lines +1013 to +1020
try:
await self.postprocess_single_output(output, data_point)
if self.should_run_evaluation and self.evaluator:
output = await self.evaluate_single_datapoint({**data_point, **output})
except Exception:
pos = data_point.get(self.cfg.async_position_key, "?")
LOG.exception("postprocess/evaluate failed for async_pos=%s", pos)
await self._output_queue.put((output, data_point))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gwarmstrong not sure if this is correct but if it is, we should probably fail on exceptions

async def _safe_one(dp):
try:
await self._generate_and_save_datapoint(dp, data, fout, pbar)
except Exception as e:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess similar here, why do we want to catch all exceptions?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants