Skip to content

fix(customizer): namespace backend schemas to fix OpenAPI name collision - #737

Merged
albcui merged 2 commits into
mainfrom
albcui/aalgo-351-customizer-openapi-schema-name-collision
Jul 17, 2026
Merged

fix(customizer): namespace backend schemas to fix OpenAPI name collision#737
albcui merged 2 commits into
mainfrom
albcui/aalgo-351-customizer-openapi-schema-name-collision

Conversation

@albcui

@albcui albcui commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Automodel and Unsloth each define models named TrainingSpec (plus LoRAParams, DatasetSpec, ScheduleSpec, BatchSpec, OptimizerSpec, OutputRequest, OutputResponse). Because both backends mount into one /apis/customization FastAPI app, Pydantic's module-qualified $defs keys were collapsed by the platform's schema-name normalizer, so both *JobInput.training ended up $ref-ing a single TrainingSpec — Unsloth's request contract silently took Automodel's shape.

Layer 1 (the fix): add a NamespacedModel metaclass base. Pydantic freezes a model's JSON-schema name at class creation, so per-backend subclasses (AutomodelSchema / UnslothSchema) prefix each owned model there, emitting AutomodelTrainingSpec / UnslothTrainingSpec. Top-level names already starting with the prefix (AutomodelJobInput, ...) are left unchanged.

Layer 2 (backstop): tweak_spec / _normalize_refs_and_schema_keys gain a strict_collisions flag. It raises on a differing-content name collision so a future un-namespaced backend fails the build with an actionable message. The flag is enabled only for the self-contained plugin specs (where the customization app lives); platform/service specs keep the legacy warn-and-collapse behavior, since they carry pre-existing collisions.

While enabling the gate it surfaced a pre-existing duplicate: two identical BackendFormat enums (core-models vs nemo_platform_plugin.inference_middleware). Dedupe by having core-models re-export the plugin's canonical enum — the plugin is the lower-level package core-models already depends on, and IGW already treats it as canonical. Behavior-preserving; the platform spec is byte-identical.

Known follow-ups (left as warnings, not fixed here): DeleteResponse and GenericSortField each collide across common / core-entities / guardrails with genuinely different content.

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features

    • Added namespaced OpenAPI schemas for customization backends (Automodel, Unsloth, RL) to reduce component name collisions when apps are merged.
    • Expanded generated job-spec schemas with backend-specific variants (training/dataset/schedule/batch/optimizer/parallelism/output, plus RL/Unsloth families).
    • Added configurable strict handling for OpenAPI schema-name collisions (warn vs fail).
  • Bug Fixes

    • Improved schema collision detection with deduplication, consistent $ref rewriting, and clearer warning/error behavior.
  • Tests

    • Added coverage for strict vs non-strict collision scenarios and namespace-based schema naming.

@albcui
albcui marked this pull request as ready for review July 16, 2026 20:11
@albcui
albcui requested review from a team as code owners July 16, 2026 20:11
@github-actions github-actions Bot added the fix label Jul 16, 2026
@albcui
albcui force-pushed the albcui/aalgo-351-customizer-openapi-schema-name-collision branch from 9bb1a9d to c17ac4c Compare July 16, 2026 20:11
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 32fd652e-1519-48f9-ac6e-3028c00e8156

📥 Commits

Reviewing files that changed from the base of the PR and between 9b7ea2c and f7dc6fc.

📒 Files selected for processing (14)
  • packages/nmp_common/src/nmp/common/api/utils.py
  • packages/nmp_common/tests/api/test_utils_openapi_spec.py
  • packages/nmp_customization_common/src/nmp/customization_common/schema.py
  • packages/nmp_customization_common/tests/test_schema.py
  • plugins/nemo-automodel/src/nemo_automodel_plugin/schema.py
  • plugins/nemo-customizer/openapi/openapi.yaml
  • plugins/nemo-customizer/pyproject.toml
  • plugins/nemo-rl/src/nemo_rl_plugin/schema.py
  • plugins/nemo-unsloth/src/nemo_unsloth_plugin/schema.py
  • script/generate_openapi_spec.py
  • script/openapi_helper/plugin_config.py
  • services/core/models/src/nmp/core/models/schemas.py
  • services/rl/src/nmp/rl/schemas.py
  • services/unsloth/src/nmp/unsloth/schemas.py
🚧 Files skipped from review as they are similar to previous changes (14)
  • plugins/nemo-customizer/pyproject.toml
  • packages/nmp_customization_common/src/nmp/customization_common/schema.py
  • script/openapi_helper/plugin_config.py
  • plugins/nemo-unsloth/src/nemo_unsloth_plugin/schema.py
  • plugins/nemo-rl/src/nemo_rl_plugin/schema.py
  • packages/nmp_common/tests/api/test_utils_openapi_spec.py
  • services/rl/src/nmp/rl/schemas.py
  • services/core/models/src/nmp/core/models/schemas.py
  • packages/nmp_common/src/nmp/common/api/utils.py
  • script/generate_openapi_spec.py
  • packages/nmp_customization_common/tests/test_schema.py
  • services/unsloth/src/nmp/unsloth/schemas.py
  • plugins/nemo-automodel/src/nemo_automodel_plugin/schema.py
  • plugins/nemo-customizer/openapi/openapi.yaml

📝 Walkthrough

Walkthrough

OpenAPI normalization now supports strict or warning-based schema collision handling. Shared namespaced Pydantic bases are adopted across Automodel, RL, and Unsloth, generated contracts use backend-specific schemas, and BackendFormat is imported from its canonical package.

Changes

Schema normalization and namespacing

Layer / File(s) Summary
Namespaced model foundation
packages/nmp_customization_common/...
NamespacedModel prefixes schema names through __schema_namespace__ and centralizes extra="forbid" behavior.
Backend schema adoption
plugins/nemo-automodel/..., services/rl/..., services/unsloth/..., plugins/nemo-rl/..., plugins/nemo-unsloth/...
Automodel, RL, and Unsloth models inherit namespace-specific bases.
Collision-aware normalization
packages/nmp_common/...
Identical normalized schemas are deduplicated; differing collisions warn by default or raise in strict mode.
Strict generation configuration
script/..., plugins/nemo-customizer/pyproject.toml
Plugin configuration controls strict collision handling during separate strict and lenient generation passes.
Plugin-scoped OpenAPI contracts
plugins/nemo-customizer/openapi/openapi.yaml
Automodel, RL, and Unsloth schemas are expanded, renamed, rewired, and connected to backend-specific job contracts.

Canonical backend format export

Layer / File(s) Summary
BackendFormat re-export
services/core/models/src/nmp/core/models/schemas.py
The local enum is replaced with an import of the shared BackendFormat type.

Sequence Diagram(s)

sequenceDiagram
  participant PluginConfig
  participant process_plugin_specs
  participant apply_schema_fixes
  participant tweak_spec
  participant OpenAPISpec
  PluginConfig->>process_plugin_specs: provide strict_schema_collisions
  process_plugin_specs->>apply_schema_fixes: split strict and lenient specifications
  apply_schema_fixes->>tweak_spec: pass strict_collisions
  tweak_spec->>OpenAPISpec: normalize schemas and rewrite references
  OpenAPISpec-->>tweak_spec: return normalized specification or raise ValueError
Loading

Possibly related PRs

Suggested reviewers: mckornfield, svvarom, maxdubrinsky

🚥 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 change: namespacing backend schemas to resolve OpenAPI name collisions.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
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 albcui/aalgo-351-customizer-openapi-schema-name-collision

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: 2

🧹 Nitpick comments (1)
packages/nmp_customization_common/src/nmp/customization_common/schema.py (1)

26-28: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop from __future__ import annotations here. There are no forward refs in this module, and keeping ClassVar[str | None] concrete avoids stringized runtime annotations.

🤖 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 `@packages/nmp_customization_common/src/nmp/customization_common/schema.py`
around lines 26 - 28, Remove the `from __future__ import annotations` import
from this module, leaving the existing `ClassVar` import and concrete
`ClassVar[str | None]` annotations unchanged.

Source: Coding guidelines

🤖 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 `@packages/nmp_common/src/nmp/common/api/utils.py`:
- Around line 179-202: Update the schema-collision handling around the
`by_target` loop and `rename_map` rebuild so each normalized name explicitly
selects and preserves the first-seen key from `old_keys`, including when that
key differs from the normalized name. Rebuild the normalized schema entry from
that representative rather than allowing an existing `Foo` key to override an
earlier `module__Foo`; retain identical-content deduplication and collision
reporting behavior.

In `@packages/nmp_customization_common/src/nmp/customization_common/schema.py`:
- Around line 42-45: The dynamic class creation in the metaclass `__new__`
currently changes `__qualname__` without ensuring the generated name is
importable. Preserve the original qualname or bind the generated alias in the
defining module, and add a pickle round-trip test covering the dynamically
prefixed class.

---

Nitpick comments:
In `@packages/nmp_customization_common/src/nmp/customization_common/schema.py`:
- Around line 26-28: Remove the `from __future__ import annotations` import from
this module, leaving the existing `ClassVar` import and concrete `ClassVar[str |
None]` annotations unchanged.
🪄 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: a71b0a6c-8c82-4a39-b152-112779d64ecf

📥 Commits

Reviewing files that changed from the base of the PR and between 81be2f7 and c17ac4c.

📒 Files selected for processing (10)
  • packages/nmp_common/src/nmp/common/api/utils.py
  • packages/nmp_common/tests/api/test_utils_openapi_spec.py
  • packages/nmp_customization_common/src/nmp/customization_common/schema.py
  • packages/nmp_customization_common/tests/test_schema.py
  • plugins/nemo-automodel/src/nemo_automodel_plugin/schema.py
  • plugins/nemo-customizer/openapi/openapi.yaml
  • plugins/nemo-unsloth/src/nemo_unsloth_plugin/schema.py
  • script/generate_openapi_spec.py
  • services/core/models/src/nmp/core/models/schemas.py
  • services/unsloth/src/nmp/unsloth/schemas.py

Comment thread packages/nmp_common/src/nmp/common/api/utils.py Outdated
@soluwalana

Copy link
Copy Markdown
Contributor

Should we update the RL parameters so that they are also namespaced for consistency even though there is no failing collisions there?

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 25556/32773 78.0% 62.6%
Integration Tests 14748/31422 46.9% 19.2%

@albcui
albcui force-pushed the albcui/aalgo-351-customizer-openapi-schema-name-collision branch from c17ac4c to 393f443 Compare July 16, 2026 20:28
@albcui

albcui commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Should we update the RL parameters so that they are also namespaced for consistency even though there is no failing collisions there?

Yea good point, RL should get the same treatment.

@albcui
albcui force-pushed the albcui/aalgo-351-customizer-openapi-schema-name-collision branch from 393f443 to 9b7ea2c Compare July 16, 2026 21:02

@mckornfield mckornfield 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.

personally I'd be for simplifying and just making exploding the default, but maybe that's a follow up?

Comment thread plugins/nemo-customizer/pyproject.toml
albcui added 2 commits July 17, 2026 13:56
Automodel and Unsloth each define models named TrainingSpec (plus
LoRAParams, DatasetSpec, ScheduleSpec, BatchSpec, OptimizerSpec,
OutputRequest, OutputResponse); the RL backend likewise defines
OutputRequest / OutputResponse. Because all backends mount into one
/apis/customization FastAPI app, Pydantic's module-qualified $defs keys
were collapsed by the platform's schema-name normalizer, so e.g. both
*JobInput.training ended up $ref-ing a single TrainingSpec — Unsloth's
request contract silently took Automodel's shape (contributors mount in
sorted() order, so Automodel won).

Layer 1 (the fix): add a NamespacedModel metaclass base. Pydantic freezes
a model's JSON-schema name at class creation, so per-backend subclasses
(AutomodelSchema / UnslothSchema / RlSchema) prefix each owned model there,
emitting AutomodelTrainingSpec / UnslothTrainingSpec / RlDPOTraining, etc.
Top-level names already starting with the prefix (AutomodelJobInput,
RlJobInput, ...) are left unchanged. All three customization backends now
namespace their own schemas, so the invariant holds as backends are added
(without this, RL's OutputRequest/OutputResponse were correct in the spec
only by accident — they won the bare name once Automodel/Unsloth vacated it).

Layer 2 (backstop): tweak_spec / _normalize_refs_and_schema_keys gain a
strict_collisions flag. It raises on a differing-content name collision so
a future un-namespaced backend fails the build with an actionable message.
A plugin opts in via [tool.nemo.openapi].strict_schema_collisions in its
pyproject (only nemo-customizer does today, since its spec merges multiple
backends into one app); every other spec keeps the legacy warn-and-collapse
behavior, since the platform specs carry pre-existing collisions.

While enabling the gate it surfaced a pre-existing duplicate: two identical
BackendFormat enums (core-models vs nemo_platform_plugin.inference_middleware).
Dedupe by having core-models re-export the plugin's canonical enum — the
plugin is the lower-level package core-models already depends on, and IGW
already treats it as canonical. Behavior-preserving; the platform spec is
byte-identical.

Known follow-ups (left as warnings, not fixed here; tracked in AALGO-352):
DeleteResponse and GenericSortField each collide across common /
core-entities / guardrails with genuinely different content.

Regenerates plugins/nemo-customizer/openapi/openapi.yaml.

Refs AALGO-351.

Signed-off-by: Albert Cui <albcui@nvidia.com>
- Document that inheriting the namespaced base applies extra="forbid"
  uniformly across all RL models, including RlJobOutput/OutputResponse
  that previously defaulted to extra="ignore" (intentional: matches the
  automodel/unsloth backends, which rehydrate via the same
  model_validate(spec.model_dump()) path without carrying foreign fields).
- Add a regression test pinning that a subclass declaring its own
  model_config still inherits extra="forbid" from the base.
- Drop the redundant BackendFormat re-export comment; the reuse is
  evident from the import.

Signed-off-by: Albert Cui <albcui@nvidia.com>
@albcui
albcui force-pushed the albcui/aalgo-351-customizer-openapi-schema-name-collision branch from 3976cb1 to f7dc6fc Compare July 17, 2026 17:56
@albcui
albcui enabled auto-merge July 17, 2026 19:29
@albcui
albcui added this pull request to the merge queue Jul 17, 2026
Merged via the queue into main with commit 992330a Jul 17, 2026
105 of 106 checks passed
@albcui
albcui deleted the albcui/aalgo-351-customizer-openapi-schema-name-collision branch July 17, 2026 19:52
albcui added a commit that referenced this pull request Jul 20, 2026
#737 fixed the Automodel/Unsloth backends, but two collisions remained across
the merged platform services and were silently collapsed by the schema-name
normalizer:

- DeleteResponse: core-entities' model (required id + deleted_count) collided
  with the shared nmp.common.api.common.DeleteResponse (id, deleted_at);
  guardrails had redefined the shared shape locally.
- GenericSortField: core-entities, guardrails, and common each defined a
  different enum under the same name.

The merge kept only the first-seen schema and repointed every $ref to it, so
delete/sort endpoints across services referenced the wrong contract in the
generated SDK.

Give each its true name (schema-name change only; JSON wire format unchanged):

- guardrails: drop the local DeleteResponse, import the byte-identical shared
  nmp.common.api.common.DeleteResponse.
- core-entities: DeleteResponse -> EntityDeleteResponse (keeps deleted_count),
  updating all four delete endpoints.
- core-entities GenericSortField -> WorkspaceSortField; guardrails
  GenericSortField -> GuardrailConfigSortField.

Regenerates openapi/openapi.yaml and the ga/ merged specs.

Refs AALGO-352.

Signed-off-by: Albert Cui <albcui@nvidia.com>
albcui added a commit that referenced this pull request Jul 20, 2026
The differing-content collision gate added in #737 was opt-in: only the
nemo-customizer plugin spec enforced it, while platform/service specs stayed on
warn-and-collapse. That is exactly how the AALGO-352 collisions shipped
silently.

With those fixed (zero collisions across the platform, all services, and all
plugins), flip the default so any future collision fails spec generation loudly
instead of shipping a wrong SDK contract:

- tweak_spec, apply_schema_fixes, and PluginConfig.strict_schema_collisions
  default to True. The plugin "lenient" branch now passes
  strict_collisions=False explicitly (it previously relied on the old default).
- A spec can still opt out via strict_collisions=False /
  [tool.nemo.openapi].strict_schema_collisions = false.
- Generalize the collision error message (rename/dedupe or namespace) and
  invert the unit tests to pin the policy: default raises, opt-out warns.

Enforcement is at spec-generation time (make refresh-openapi / the manual
openapi-generator pre-commit hook). CI does not regenerate the spec today, so
this does not yet gate PRs on its own; adding a CI regen/drift check is a
follow-up.

Refs AALGO-352.

Signed-off-by: Albert Cui <albcui@nvidia.com>
albcui added a commit that referenced this pull request Jul 20, 2026
#737 fixed the Automodel/Unsloth backends, but two collisions remained across
the merged platform services and were silently collapsed by the schema-name
normalizer:

- DeleteResponse: core-entities' model (required id + deleted_count) collided
  with the shared nmp.common.api.common.DeleteResponse (id, deleted_at);
  guardrails had redefined the shared shape locally.
- GenericSortField: core-entities, guardrails, and common each defined a
  different enum under the same name.

The merge kept only the first-seen schema and repointed every $ref to it, so
delete/sort endpoints across services referenced the wrong contract in the
generated SDK.

Give each its true name (schema-name change only; JSON wire format unchanged):

- guardrails: drop the local DeleteResponse, import the byte-identical shared
  nmp.common.api.common.DeleteResponse.
- core-entities: DeleteResponse -> EntityDeleteResponse (keeps deleted_count),
  updating all four delete endpoints.
- core-entities GenericSortField -> WorkspaceSortField; guardrails
  GenericSortField -> GuardrailConfigSortField.

Regenerates openapi/openapi.yaml and the ga/ merged specs.

Refs AALGO-352.

Signed-off-by: Albert Cui <albcui@nvidia.com>
albcui added a commit that referenced this pull request Jul 20, 2026
The differing-content collision gate added in #737 was opt-in: only the
nemo-customizer plugin spec enforced it, while platform/service specs stayed on
warn-and-collapse. That is exactly how the AALGO-352 collisions shipped
silently.

With those fixed (zero collisions across the platform, all services, and all
plugins), flip the default so any future collision fails spec generation loudly
instead of shipping a wrong SDK contract:

- tweak_spec, apply_schema_fixes, and PluginConfig.strict_schema_collisions
  default to True. The plugin "lenient" branch now passes
  strict_collisions=False explicitly (it previously relied on the old default).
- A spec can still opt out via strict_collisions=False /
  [tool.nemo.openapi].strict_schema_collisions = false.
- Generalize the collision error message (rename/dedupe or namespace) and
  invert the unit tests to pin the policy: default raises, opt-out warns.

Enforcement is at spec-generation time (make refresh-openapi / the manual
openapi-generator pre-commit hook). CI does not regenerate the spec today, so
this does not yet gate PRs on its own; adding a CI regen/drift check is a
follow-up.

Refs AALGO-352.

Signed-off-by: Albert Cui <albcui@nvidia.com>
albcui added a commit that referenced this pull request Jul 20, 2026
#737 fixed the Automodel/Unsloth backends, but two collisions remained across
the merged platform services and were silently collapsed by the schema-name
normalizer:

- DeleteResponse: core-entities' model (required id + deleted_count) collided
  with the shared nmp.common.api.common.DeleteResponse (id, deleted_at);
  guardrails had redefined the shared shape locally.
- GenericSortField: core-entities, guardrails, and common each defined a
  different enum under the same name.

The merge kept only the first-seen schema and repointed every $ref to it, so
delete/sort endpoints across services referenced the wrong contract in the
generated SDK.

Give each its true name (schema-name change only; JSON wire format unchanged):

- guardrails: drop the local DeleteResponse, import the byte-identical shared
  nmp.common.api.common.DeleteResponse.
- core-entities: DeleteResponse -> EntityDeleteResponse (keeps deleted_count),
  updating all four delete endpoints.
- core-entities GenericSortField -> WorkspaceSortField; guardrails
  GenericSortField -> GuardrailConfigSortField.

Regenerates openapi/openapi.yaml and the ga/ merged specs.

Refs AALGO-352.

Signed-off-by: Albert Cui <albcui@nvidia.com>
albcui added a commit that referenced this pull request Jul 20, 2026
The differing-content collision gate added in #737 was opt-in: only the
nemo-customizer plugin spec enforced it, while platform/service specs stayed on
warn-and-collapse. That is exactly how the AALGO-352 collisions shipped
silently.

With those fixed (zero collisions across the platform, all services, and all
plugins), flip the default so any future collision fails spec generation loudly
instead of shipping a wrong SDK contract:

- tweak_spec, apply_schema_fixes, and PluginConfig.strict_schema_collisions
  default to True. The plugin "lenient" branch now passes
  strict_collisions=False explicitly (it previously relied on the old default).
- A spec can still opt out via strict_collisions=False /
  [tool.nemo.openapi].strict_schema_collisions = false.
- Generalize the collision error message (rename/dedupe or namespace) and
  invert the unit tests to pin the policy: default raises, opt-out warns.

Enforcement is at spec-generation time (make refresh-openapi / the manual
openapi-generator pre-commit hook). CI does not regenerate the spec today, so
this does not yet gate PRs on its own; adding a CI regen/drift check is a
follow-up.

Refs AALGO-352.

Signed-off-by: Albert Cui <albcui@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants