Skip to content

fix(server): honor api_key env vars when overriding main model#1923

Open
adityasingh2400 wants to merge 4 commits into
NVIDIA-NeMo:developfrom
adityasingh2400:adityasingh2400/server-api-key-env
Open

fix(server): honor api_key env vars when overriding main model#1923
adityasingh2400 wants to merge 4 commits into
NVIDIA-NeMo:developfrom
adityasingh2400:adityasingh2400/server-api-key-env

Conversation

@adityasingh2400
Copy link
Copy Markdown

@adityasingh2400 adityasingh2400 commented May 23, 2026

Closes #1895.

When a request to /v1/chat/completions supplies a model field, the server rebuilds the main model entry by constructing a fresh Model from the request value plus MAIN_MODEL_ENGINE and MAIN_MODEL_BASE_URL, then hands it to _update_models_in_config to swap into the loaded RailsConfig. That swap replaced the existing entry wholesale, so any api_key_env_var declared in config.yml was silently dropped. The downstream initializer then had no env-var hint and the credential resolution path that the CLI and non-server code uses was never taken, surfacing as an invalid-API-key error from the upstream provider.

This change carries api_key_env_var over from the existing main model entry when the override does not set one explicitly, so a config.yml that points at a custom environment variable continues to resolve credentials correctly when the model name is swapped at request time. Explicit api_key_env_var on the override still takes precedence. Implicit env-var fallback for engines like openai continues to work via the default framework, which was already correct on the no-override path.

Added regression coverage on _update_models_in_config for both the preserve-from-config and override-wins paths, plus an integration-level test that drives _get_rails end to end with OPENAI_API_KEY set and a request-side model override, asserting the resolved HTTP client carries the env-derived key.

Summary by CodeRabbit

  • Bug Fixes
    • Model overrides now preserve existing credential configuration fields when not explicitly set in the override request.
    • Environment-based credential resolution continues to work correctly when applying model overrides.

Review Change Stack

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 23, 2026

Greptile Summary

This PR fixes a credential-dropping bug (#1895) in the server's request-driven model override path. The old code replaced the entire Model entry and only patched parameters back, silently discarding api_key_env_var and other config-sourced fields; the new approach starts from the existing entry and applies only the fields the override explicitly sets via model_dump(exclude_unset=True) + model_copy, making the carryover automatic for all current and future Model fields.

  • api.py: _update_models_in_config is rewritten from "replace-then-patch" to "start-from-existing, apply-only-what-was-set", preserving api_key_env_var, mode, cache, and parameters by default.
  • test_api.py: Four regression tests added — api_key_env_var preserve, override-wins, mode/cache preserve, and an end-to-end _get_rails integration test with env-var credential resolution.

Confidence Score: 5/5

Safe to merge — the change is a targeted, backward-compatible fix with comprehensive regression coverage.

The rewrite of _update_models_in_config uses a well-understood Pydantic v2 idiom (model_dump(exclude_unset=True) + model_copy) that automatically preserves all unset fields rather than requiring per-field carryover. No existing behavior is regressed: the parameters-merge logic is preserved with override precedence, the else branch for a missing main model entry is unchanged, and four targeted tests validate the preserve, override-wins, mode/cache, and end-to-end credential paths.

No files require special attention.

Important Files Changed

Filename Overview
nemoguardrails/server/api.py Replaces the fragile field-by-field carryover in _update_models_in_config with a generic model_dump(exclude_unset=True) + model_copy approach, so all unset fields on the override (including api_key_env_var, mode, and cache) are transparently preserved from the existing config entry.
tests/server/test_api.py Adds four targeted regression tests: two unit tests for the preserve-from-config and override-wins cases, one for mode/cache preservation, and one end-to-end integration test for env-var credential resolution through _get_rails.

Sequence Diagram

sequenceDiagram
    participant Client
    participant API as /v1/chat/completions
    participant GR as _get_rails
    participant UM as _update_models_in_config
    participant RC as RailsConfig

    Client->>API: POST with model override
    API->>GR: _get_rails(config_ids, model_name)
    GR->>RC: RailsConfig.from_path(full_path)
    Note over RC: existing entry has api_key_env_var,<br/>mode, cache from config.yml
    GR->>GR: Build override Model with only model, type, engine, parameters set
    GR->>UM: _update_models_in_config(config, override)
    UM->>UM: "model_dump(exclude_unset=True) yields only explicitly-set fields"
    Note over UM: api_key_env_var, mode, cache not in override dict
    UM->>UM: "existing.model_copy(update=override)"
    Note over UM: Unset fields preserved from existing config entry
    UM->>GR: updated RailsConfig
    GR->>Client: LLMRails with correct credentials
Loading

Reviews (4): Last reviewed commit: "refactor(server): carry over all unset m..." | Re-trigger Greptile

Comment thread nemoguardrails/server/api.py Outdated
Comment on lines +327 to +333
# Preserve fields from the existing entry that the override does not
# set, so a request-side model swap inherits credential resolution
# (api_key_env_var) and other model-level settings from config.yml.
api_key_env_var = main_model.api_key_env_var or existing.api_key_env_var
models[main_model_index] = main_model
models[main_model_index].parameters = parameters
models[main_model_index].api_key_env_var = api_key_env_var
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.

P2 Only api_key_env_var preserved despite broader comment

The docstring says "Fields not explicitly set on the override model … are carried over from the existing entry" and the inline comment mentions "and other model-level settings from config.yml", but in practice only api_key_env_var is restored. Fields like mode (defaults to "chat") and cache from the existing entry are still silently dropped when models[main_model_index] = main_model runs. A config that declares mode: text would lose that after a request-driven model swap, just like api_key_env_var did before this fix.

Prompt To Fix With AI
This is a comment left during a code review.
Path: nemoguardrails/server/api.py
Line: 327-333

Comment:
**Only `api_key_env_var` preserved despite broader comment**

The docstring says "Fields not explicitly set on the override model … are carried over from the existing entry" and the inline comment mentions "and other model-level settings from config.yml", but in practice only `api_key_env_var` is restored. Fields like `mode` (defaults to `"chat"`) and `cache` from the existing entry are still silently dropped when `models[main_model_index] = main_model` runs. A config that declares `mode: text` would lose that after a request-driven model swap, just like `api_key_env_var` did before this fix.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread nemoguardrails/server/api.py Outdated
# Preserve fields from the existing entry that the override does not
# set, so a request-side model swap inherits credential resolution
# (api_key_env_var) and other model-level settings from config.yml.
api_key_env_var = main_model.api_key_env_var or existing.api_key_env_var
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.

P2 Using or to check whether the override set api_key_env_var conflates None (not set) with "" (explicitly set to empty string). While env-var names are never empty in practice, is not None makes the intent explicit and is marginally safer if the field is ever set programmatically.

Suggested change
api_key_env_var = main_model.api_key_env_var or existing.api_key_env_var
api_key_env_var = main_model.api_key_env_var if main_model.api_key_env_var is not None else existing.api_key_env_var
Prompt To Fix With AI
This is a comment left during a code review.
Path: nemoguardrails/server/api.py
Line: 330

Comment:
Using `or` to check whether the override set `api_key_env_var` conflates `None` (not set) with `""` (explicitly set to empty string). While env-var names are never empty in practice, `is not None` makes the intent explicit and is marginally safer if the field is ever set programmatically.

```suggestion
        api_key_env_var = main_model.api_key_env_var if main_model.api_key_env_var is not None else existing.api_key_env_var
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread tests/server/test_api.py
Comment on lines +1052 to +1053
api.llm_rails_instances.clear()

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.

P2 Fragile private-API access in integration test

llm_rails.llm._client is a raw attribute access (not guarded by getattr), so if the OpenAI SDK restructures its client object the test will raise AttributeError rather than a clear assertion failure, obscuring the diagnosis. Meanwhile the getattr(..., "_api_key", None) default only silently swallows changes to the inner attribute, making the fallback behaviour inconsistent: one level of indirection raises, the other returns None. Wrapping both in getattr or using the public api_key surface of the OpenAI client (if available) would give more stable, readable failure messages.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/server/test_api.py
Line: 1052-1053

Comment:
**Fragile private-API access in integration test**

`llm_rails.llm._client` is a raw attribute access (not guarded by `getattr`), so if the OpenAI SDK restructures its client object the test will raise `AttributeError` rather than a clear assertion failure, obscuring the diagnosis. Meanwhile the `getattr(..., "_api_key", None)` default only silently swallows changes to the inner attribute, making the fallback behaviour inconsistent: one level of indirection raises, the other returns `None`. Wrapping both in `getattr` or using the public `api_key` surface of the OpenAI client (if available) would give more stable, readable failure messages.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 `@nemoguardrails/server/api.py`:
- Line 330: The current assignment uses `or` which treats falsy explicit
overrides (e.g., `""`) as absent; change the logic so
`main_model.api_key_env_var` wins when it is explicitly provided by checking `is
not None` and otherwise falling back to `existing.api_key_env_var` (i.e.,
implement an explicit None-check for `main_model.api_key_env_var` rather than
using `or` where `api_key_env_var` is set).
🪄 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: 349b5b44-7f34-4a2e-bfa7-63e14378fbb0

📥 Commits

Reviewing files that changed from the base of the PR and between e3715f9 and 9ccf35c.

📒 Files selected for processing (2)
  • nemoguardrails/server/api.py
  • tests/server/test_api.py

Comment thread nemoguardrails/server/api.py Outdated
# Preserve fields from the existing entry that the override does not
# set, so a request-side model swap inherits credential resolution
# (api_key_env_var) and other model-level settings from config.yml.
api_key_env_var = main_model.api_key_env_var or existing.api_key_env_var
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use is not None precedence for api_key_env_var override.

Line 330 uses or, so an explicit override like api_key_env_var="" is treated as absent and the existing value is kept. That conflicts with the “explicit override wins” contract.

🔧 Proposed fix
-        api_key_env_var = main_model.api_key_env_var or existing.api_key_env_var
+        api_key_env_var = (
+            main_model.api_key_env_var
+            if main_model.api_key_env_var is not None
+            else existing.api_key_env_var
+        )
📝 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
api_key_env_var = main_model.api_key_env_var or existing.api_key_env_var
api_key_env_var = (
main_model.api_key_env_var
if main_model.api_key_env_var is not None
else existing.api_key_env_var
)
🤖 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 `@nemoguardrails/server/api.py` at line 330, The current assignment uses `or`
which treats falsy explicit overrides (e.g., `""`) as absent; change the logic
so `main_model.api_key_env_var` wins when it is explicitly provided by checking
`is not None` and otherwise falling back to `existing.api_key_env_var` (i.e.,
implement an explicit None-check for `main_model.api_key_env_var` rather than
using `or` where `api_key_env_var` is set).

@codecov
Copy link
Copy Markdown

codecov Bot commented May 23, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

The previous `or` based check treated an explicitly set empty string the
same as None, so an override that set api_key_env_var="" would fall back
to the existing config entry instead of winning. Use an explicit None
check so the override wins whenever the caller sets the field, even to
an empty value.
@adityasingh2400
Copy link
Copy Markdown
Author

Switched the override check from or to is not None so an explicitly set empty string wins over the existing entry. Leaving the broader carry-over of mode/cache and the integration test's private attribute access for a follow-up since neither was part of the original credential-drop fix and I want to keep this PR focused.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 24, 2026

📝 Walkthrough

Walkthrough

Updated _update_models_in_config to preserve api_key_env_var from existing config when model overrides do not explicitly set it, while merging parameters with override precedence. Added three regression tests validating credential preservation, explicit override behavior, and end-to-end credential resolution with model overrides.

Changes

Model Override Credential and Parameter Merge

Layer / File(s) Summary
Override merge logic and documentation
nemoguardrails/server/api.py
Docstring expanded to clarify that unset override fields are carried over from existing config. Merge logic updated to combine parameters (existing merged with override, override wins on collision) and preserve api_key_env_var from existing config when override does not set it.
Regression tests for override behavior
tests/server/test_api.py
Three test cases validate: (1) api_key_env_var is preserved when override changes model name or parameters; (2) explicit override api_key_env_var takes precedence over existing; (3) end-to-end _get_rails with model override resolves credentials from OPENAI_API_KEY environment variable when config omits api_key_env_var.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(server): honor api_key env vars when overriding main model' directly and concisely describes the main change: ensuring environment variable credentials are preserved when the main model is overridden at request time.
Linked Issues check ✅ Passed The PR fully addresses issue #1895: it preserves api_key_env_var from config.yml when the main model is overridden at request time, ensuring environment-variable-based credential resolution works correctly. Three regression tests validate both the preserve-from-config and override-wins paths.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the api_key_env_var preservation bug: modifications to _update_models_in_config logic, three focused regression tests, and no unrelated alterations to the codebase.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Test Results For Major Changes ✅ Passed PR adds 3 comprehensive regression tests (unit and integration) covering the bug fix for credential resolution when overriding main model; tests are documented in PR summary.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/server/test_api.py (1)

1030-1065: ⚡ Quick win

Exercise the preserved api_key_env_var path end-to-end.

This test only covers OpenAI's implicit OPENAI_API_KEY fallback. It would still pass on the pre-fix implementation, because dropping api_key_env_var during the override does not matter when the provider later resolves credentials from its default env var anyway. To lock in the actual regression, make the config declare a non-default api_key_env_var and leave OPENAI_API_KEY unset.

Proposed test adjustment
     (config_dir / "config.yml").write_text(
-        "colang_version: '1.0'\nmodels:\n  - type: main\n    engine: openai\n    model: gpt-3.5-turbo\n"
+        "colang_version: '1.0'\nmodels:\n  - type: main\n    engine: openai\n    model: gpt-3.5-turbo\n    api_key_env_var: MY_CUSTOM_OPENAI_KEY\n"
     )
 
-    monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env-1895")
+    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+    monkeypatch.setenv("MY_CUSTOM_OPENAI_KEY", "sk-from-env-1895")
     monkeypatch.setenv("MAIN_MODEL_ENGINE", "openai")
     monkeypatch.setenv("MAIN_MODEL_BASE_URL", "https://api.openai.com/v1")
🤖 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/server/test_api.py` around lines 1030 - 1065, Update the test to
exercise the preserved api_key_env_var path end-to-end by having the config
declare a non-default env var name and ensuring OPENAI_API_KEY is not used:
change the config YAML in
test_get_rails_with_model_override_resolves_env_credentials to include
api_key_env_var: CUSTOM_API_KEY (or another unique name), remove or
monkeypatch.clear the OPENAI_API_KEY env var, set
monkeypatch.setenv("CUSTOM_API_KEY","sk-from-env-1895") instead of
OPENAI_API_KEY, and keep the rest of the setup and assertion against
llm_rails.llm._client._api_key so the test verifies credentials are resolved
from the declared api_key_env_var when calling api._get_rails.
🤖 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.

Nitpick comments:
In `@tests/server/test_api.py`:
- Around line 1030-1065: Update the test to exercise the preserved
api_key_env_var path end-to-end by having the config declare a non-default env
var name and ensuring OPENAI_API_KEY is not used: change the config YAML in
test_get_rails_with_model_override_resolves_env_credentials to include
api_key_env_var: CUSTOM_API_KEY (or another unique name), remove or
monkeypatch.clear the OPENAI_API_KEY env var, set
monkeypatch.setenv("CUSTOM_API_KEY","sk-from-env-1895") instead of
OPENAI_API_KEY, and keep the rest of the setup and assertion against
llm_rails.llm._client._api_key so the test verifies credentials are resolved
from the declared api_key_env_var when calling api._get_rails.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 1a9b648b-6022-47ea-9c0e-18db7a9cdddc

📥 Commits

Reviewing files that changed from the base of the PR and between e3715f9 and cbbf2b3.

📒 Files selected for processing (2)
  • nemoguardrails/server/api.py
  • tests/server/test_api.py

@adityasingh2400
Copy link
Copy Markdown
Author

Pushed a formatting-only commit. Lint was failing because ruff-format wanted the api_key_env_var ternary collapsed onto a single line, which fits within the 120 char limit. No logic change.

@m-misiura
Copy link
Copy Markdown
Contributor

@adityasingh2400 -- I also encountered this error recently, so thanks for the PR

IIUC, your fix patches api_key_env_var back after replacement, but mode and cache are still silently dropped. Moreover, every future field added to Model will need to be manually carried over here — or hit the same class of bug again.

Perhaps a more robust approach would be to start from the existing model and only override the fields the caller actually changes, using Pydantic's model_copy(update=...) along the lines of

if main_model_index is not None:
    existing = models[main_model_index]
    models[main_model_index] = existing.model_copy(update={
        "model": main_model.model,
        "engine": main_model.engine,
        "parameters": {**existing.parameters, **main_model.parameters},
    })
else:
    models.append(main_model)

WDYT @adityasingh2400?

cc @Pouyanpi @tgasser-nv

…rride

Addresses review feedback on this PR. Instead of replacing the existing main
model entry and patching individual fields back (which still dropped mode and
cache, and would miss any field added to Model later), start from the existing
entry and apply only the fields the override request explicitly set, via
existing.model_copy(update=main_model.model_dump(exclude_unset=True)).
Parameters keep override-wins merge semantics, and api_key_env_var, mode, cache,
and any future Model field are preserved when the override leaves them unset.
Adds a regression test covering mode and cache preservation.

Signed-off-by: Aditya Singh <adisin650@gmail.com>
@adityasingh2400
Copy link
Copy Markdown
Author

Thanks, and good catch confirming the broader problem. You are right that patching api_key_env_var back was a band aid. mode and cache were still dropped, and every new Model field would re-hit the same class of bug.

I pushed f2eeaae taking your approach. It now starts from the existing entry and applies only the fields the override actually set:

override = main_model.model_dump(exclude_unset=True)
if "parameters" in override:
    override["parameters"] = {**existing.parameters, **main_model.parameters}
models[main_model_index] = existing.model_copy(update=override)

I used model_dump(exclude_unset=True) rather than naming model, engine, and parameters explicitly, so that any field the caller did set (including an explicit api_key_env_var, mode, or cache) still wins, while anything left unset is carried over from config.yml. Parameters keep the override-wins merge, so a request extends the config.yml parameters instead of replacing them.

I also added a regression test that sets mode=text and a cache config on the existing entry, overrides only the model name, and asserts mode, cache, api_key_env_var, and the existing parameters all survive. The earlier api_key_env_var tests still pass, and ruff is clean.

Happy to switch to an explicit field list instead if you would prefer that, just let me know.

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.

bug: current version haven't handle llm provider api_key env variable

2 participants