-
Notifications
You must be signed in to change notification settings - Fork 17
fix(customizer): namespace backend schemas to fix OpenAPI name collision #737
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
albcui
merged 2 commits into
main
from
albcui/aalgo-351-customizer-openapi-schema-name-collision
Jul 17, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 59 additions & 0 deletions
59
packages/nmp_customization_common/src/nmp/customization_common/schema.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Namespaced Pydantic base for customization-backend schemas. | ||
|
|
||
| Every backend (automodel, unsloth, …) is merged into one FastAPI app under | ||
| ``/apis/customization``, so two backends that each define e.g. ``TrainingSpec`` | ||
| collide in the generated OpenAPI. Pydantic freezes a model's JSON-schema name at | ||
| class-creation time, so the prefix has to be applied there — this metaclass does | ||
| it. ``class TrainingSpec(AutomodelSchema)`` is emitted as ``AutomodelTrainingSpec``. | ||
|
|
||
| Usage: declare a per-backend subclass that sets ``__schema_namespace__`` and | ||
| inherit *that* from every model the backend owns:: | ||
|
|
||
| class AutomodelSchema(NamespacedModel): | ||
| __schema_namespace__ = "Automodel" | ||
|
|
||
| class TrainingSpec(AutomodelSchema): # emitted as ``AutomodelTrainingSpec`` | ||
| ... | ||
|
|
||
| A model whose class name already starts with the prefix (e.g. | ||
| ``AutomodelJobInput``) is left unchanged, so top-level request/response names stay | ||
| stable. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import ClassVar | ||
|
|
||
| from pydantic import BaseModel, ConfigDict | ||
|
|
||
| # ``type(BaseModel)`` is pydantic's ``ModelMetaclass``; deriving from it this way | ||
| # avoids importing pydantic internals. | ||
| _ModelMeta = type(BaseModel) | ||
|
|
||
|
|
||
| class _NamespacedMeta(_ModelMeta): | ||
| def __new__(mcs, name, bases, ns, **kw): | ||
| prefix = ns.get("__schema_namespace__") or next( | ||
| (p for b in bases if (p := getattr(b, "__schema_namespace__", None))), None | ||
| ) | ||
| if prefix and not name.startswith(prefix): # don't double-prefix e.g. AutomodelJobInput | ||
| name = f"{prefix}{name}" | ||
| ns["__qualname__"] = name | ||
| return super().__new__(mcs, name, bases, ns, **kw) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
|
|
||
| class NamespacedModel(BaseModel, metaclass=_NamespacedMeta): | ||
| """Base for backend schemas. | ||
|
|
||
| Declare a per-backend subclass that sets ``__schema_namespace__`` and inherit | ||
| *that* from every model, so each backend's schemas emit distinct component | ||
| names in the merged ``/apis/customization`` OpenAPI spec. | ||
| """ | ||
|
|
||
| # Dunder ClassVar → pydantic ignores it as a field; the metaclass reads it at | ||
| # class-creation time to compute the emitted schema name. | ||
| __schema_namespace__: ClassVar[str | None] = None | ||
| model_config = ConfigDict(extra="forbid") # the config every backend model already sets | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Unit tests for the ``NamespacedModel`` backend-schema base. | ||
|
|
||
| These pin the behaviour the customization OpenAPI fix relies on: each backend's | ||
| models emit a namespace-prefixed schema name at *class-creation time*, so two | ||
| backends that each define e.g. ``TrainingSpec`` don't collapse into one component | ||
| when merged into the single ``/apis/customization`` FastAPI app. | ||
| """ | ||
|
|
||
| import pytest | ||
| from nmp.customization_common.schema import NamespacedModel | ||
| from pydantic import Field, ValidationError | ||
|
|
||
|
|
||
| class AutomodelSchema(NamespacedModel): | ||
| __schema_namespace__ = "Automodel" | ||
|
|
||
|
|
||
| class UnslothSchema(NamespacedModel): | ||
| __schema_namespace__ = "Unsloth" | ||
|
|
||
|
|
||
| # Two backends each define a leaf literally named ``TrainingSpec`` — exactly the | ||
| # collision the metaclass exists to prevent. Capture each binding under an alias | ||
| # before the next ``class TrainingSpec`` statement shadows the module name. | ||
| class TrainingSpec(AutomodelSchema): | ||
| lr: float = 1e-4 | ||
|
|
||
|
|
||
| AutomodelTrainingSpec = TrainingSpec | ||
|
|
||
|
|
||
| class TrainingSpec(UnslothSchema): # noqa: F811 — intentional same-name redefinition | ||
| steps: int = 100 | ||
|
|
||
|
|
||
| UnslothTrainingSpec = TrainingSpec | ||
|
|
||
|
|
||
| class AutomodelJobInput(AutomodelSchema): | ||
| training: AutomodelTrainingSpec = Field(default_factory=AutomodelTrainingSpec) | ||
|
|
||
|
|
||
| def test_leaf_models_are_prefixed_at_class_creation(): | ||
| assert AutomodelTrainingSpec.__name__ == "AutomodelTrainingSpec" | ||
| assert UnslothTrainingSpec.__name__ == "UnslothTrainingSpec" | ||
|
|
||
|
|
||
| def test_same_leaf_name_across_backends_emits_distinct_component_names(): | ||
| a_title = AutomodelTrainingSpec.model_json_schema()["title"] | ||
| u_title = UnslothTrainingSpec.model_json_schema()["title"] | ||
| assert a_title == "AutomodelTrainingSpec" | ||
| assert u_title == "UnslothTrainingSpec" | ||
| # The two normalize to different bare names, so the platform normalizer won't | ||
| # collapse them into one component. | ||
| assert a_title != u_title | ||
|
|
||
|
|
||
| def test_nested_ref_points_at_the_owning_backends_schema(): | ||
| schema = AutomodelJobInput.model_json_schema() | ||
| assert "AutomodelTrainingSpec" in schema["$defs"] | ||
| assert schema["properties"]["training"]["$ref"].endswith("/AutomodelTrainingSpec") | ||
|
|
||
|
|
||
| def test_top_level_name_already_prefixed_is_not_double_prefixed(): | ||
| assert AutomodelJobInput.__name__ == "AutomodelJobInput" | ||
| assert AutomodelJobInput.model_json_schema()["title"] == "AutomodelJobInput" | ||
|
|
||
|
|
||
| def test_backend_base_class_itself_is_not_prefixed(): | ||
| # The intermediate per-backend base keeps its own name (it is never emitted | ||
| # as a request/response schema, but must not be mangled either). | ||
| assert AutomodelSchema.__name__ == "AutomodelSchema" | ||
| assert UnslothSchema.__name__ == "UnslothSchema" | ||
|
|
||
|
|
||
| def test_extra_forbid_is_inherited(): | ||
| assert AutomodelJobInput.model_config.get("extra") == "forbid" | ||
| assert AutomodelJobInput.model_json_schema()["additionalProperties"] is False | ||
| with pytest.raises(ValidationError): | ||
| # Extra field rejected (validated via dict so the extra key isn't a | ||
| # static type error); training defaults, so ``bogus`` is the only fault. | ||
| AutomodelJobInput.model_validate({"training": {}, "bogus": 1}) | ||
|
|
||
|
|
||
| def test_schema_namespace_is_not_a_field(): | ||
| assert "__schema_namespace__" not in AutomodelJobInput.model_fields | ||
| assert AutomodelSchema.__schema_namespace__ == "Automodel" | ||
|
|
||
|
|
||
| def test_plain_namespaced_model_without_namespace_is_unprefixed(): | ||
| class Bare(NamespacedModel): | ||
| x: int = 0 | ||
|
|
||
| assert Bare.__name__ == "Bare" | ||
| assert Bare.model_json_schema()["title"] == "Bare" | ||
|
|
||
|
|
||
| def test_subclass_with_own_model_config_still_inherits_extra_forbid(): | ||
| """A subclass that declares its OWN ``model_config`` must still inherit | ||
| ``extra='forbid'`` from the base. | ||
|
|
||
| ``RlJobInput``, ``_TrainingBase`` and ``RlJobOutput`` each set | ||
| ``ConfigDict(protected_namespaces=())`` and rely on pydantic *merging* (not | ||
| replacing) the base config to keep rejecting unknown fields. If that ever | ||
| regressed, ``additionalProperties: false`` would silently vanish from those | ||
| request bodies and this suite would still pass without this guard. | ||
| """ | ||
| from pydantic import ConfigDict | ||
|
|
||
| class WithOwnConfig(AutomodelSchema): | ||
| model_config = ConfigDict(protected_namespaces=()) | ||
|
|
||
| value: int = 0 | ||
|
|
||
| # Subclass's own key applied *and* the base's ``extra='forbid'`` preserved. | ||
| assert WithOwnConfig.model_config.get("protected_namespaces") == () | ||
| assert WithOwnConfig.model_config.get("extra") == "forbid" | ||
| assert WithOwnConfig.model_json_schema()["additionalProperties"] is False | ||
| with pytest.raises(ValidationError): | ||
| WithOwnConfig.model_validate({"value": 1, "bogus": 2}) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.