From 0fe08570fc416b538fd8750bb0856cfe38e351dd Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 29 Jul 2026 14:22:42 -0700 Subject: [PATCH 01/12] fix(api): declare the entity-store name pattern on create DTOs [ASTD-349] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec advertised `^[\w\-.]+$` (max 255) for fileset, model provider, and secret names while the entity store enforces the stricter RFC-1035-ish NAME_PATTERN downstream. Names like `Sparl` or `My_Provider` passed every generated client's validation and then failed with a 422, so the published contract was wrong for all SDK consumers, not just Studio. Declare NAME_PATTERN on the three create-request DTOs. Each needs regex_engine="python-re" — the pattern uses lookaround, which Pydantic's default Rust engine rejects — matching what the entity-store schemas already do. max_length drops 255 -> 63 to agree with the regex, which caps at 63 by construction. The secrets DTO enforced its rule in a field_validator, so no pattern reached the spec at all; moving it to `pattern=` publishes it. Its uppercase-name regression tests now bypass the request model with model_construct so they still exercise the server-side 422 path. `@` is legal under NAME_PATTERN, so secrets now accept `a@b`. The entity store always did. Signed-off-by: mschwab --- openapi/ga/individual/platform.openapi.yaml | 25 +++++++----- openapi/ga/openapi.yaml | 25 +++++++----- openapi/openapi.yaml | 25 +++++++----- .../src/nemo_platform_plugin/files/types.py | 21 ++++++---- .../src/nemo_platform_plugin/secrets/types.py | 38 +++++++++---------- .../src/nmp/common/entities/constants.py | 2 + .../models/src/nmp/core/models/schemas.py | 10 +++-- .../integration/test_secrets_with_auth.py | 20 ++++++++-- services/core/secrets/tests/test_secrets.py | 22 +++++++---- 9 files changed, 116 insertions(+), 72 deletions(-) diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index 234a7c54ae..38190d9fef 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -9416,11 +9416,12 @@ components: properties: name: type: string - maxLength: 255 - pattern: ^[\w\-.]+$ + maxLength: 63 + pattern: ^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? str: - if not _NAME_RE.match(v): - raise ValueError( - f"Invalid secret name '{v}'. Allowed characters: letters, digits, underscores, " - "hyphens, and dots. Example: my-api-key" - ) - return v - @field_serializer("value", when_used="json") def _serialize_value(self, value: SecretStr) -> str: return value.get_secret_value() diff --git a/packages/nmp_common/src/nmp/common/entities/constants.py b/packages/nmp_common/src/nmp/common/entities/constants.py index 5977d6baa6..937c86ffe8 100644 --- a/packages/nmp_common/src/nmp/common/entities/constants.py +++ b/packages/nmp_common/src/nmp/common/entities/constants.py @@ -12,6 +12,8 @@ # TODO(#3530): Remove @, ., +, _ once versioning is implemented and predefined target names (e.g., llama-3.2-3b-instruct@v1.0.0+A100) are updated. NAME_PATTERN = r"^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? str: @@ -473,10 +473,12 @@ class ModelProviderSort(StrEnum): class CreateModelProviderRequest(BaseModel): """Request model for creating a ModelProvider.""" + model_config = ConfigDict(regex_engine="python-re") + name: str = Field( - description=f"Name of the model provider. {constants.REGEX_WORD_CHARACTER_DOT_DASH_DESCRIPTION}", - max_length=constants.MAX_LENGTH_255, - pattern=constants.REGEX_WORD_CHARACTER_DOT_DASH, + description=f"Name of the model provider. {constants.NAME_PATTERN_DESCRIPTION}", + max_length=constants.NAME_MAX_LENGTH, + pattern=constants.NAME_PATTERN, examples=["my-nim-provider", "openai-endpoint"], ) project: Optional[str] = Field( diff --git a/services/core/secrets/tests/integration/test_secrets_with_auth.py b/services/core/secrets/tests/integration/test_secrets_with_auth.py index f1caac08d6..58c04fc908 100644 --- a/services/core/secrets/tests/integration/test_secrets_with_auth.py +++ b/services/core/secrets/tests/integration/test_secrets_with_auth.py @@ -34,7 +34,7 @@ short_unique_name, unique_email, ) -from pydantic import SecretStr +from pydantic import SecretStr, ValidationError # Service principals have elevated access (like platform admin) SERVICE_PRINCIPAL = "service:integration-test" @@ -997,9 +997,17 @@ def test_create_secret_with_uppercase_returns_422(self, sdk: NeMoPlatform): # Name with uppercase letter - violates DNS-compliant naming rules invalid_name = "test-secret-123-Test" + with pytest.raises(ValidationError) as local_exc: + PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")) + assert "should match pattern" in str(local_exc.value).lower() + + # model_construct skips validation so the bad name still reaches the server, + # which is where the 500-vs-422 regression lived. with pytest.raises(ClientUnprocessableEntityError) as exc_info: admin_secrets.create_secret( - body=PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")), + body=PlatformSecretCreateRequest.model_construct( + name=invalid_name, value=SecretStr("test-value"), description=None + ), workspace="default", ) @@ -1016,9 +1024,15 @@ def test_create_secret_with_all_uppercase_returns_422(self, sdk: NeMoPlatform): admin_secrets = client_from_platform(admin_sdk, SecretsClient) invalid_name = "TEST-SECRET" + with pytest.raises(ValidationError) as local_exc: + PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")) + assert "should match pattern" in str(local_exc.value).lower() + with pytest.raises(ClientUnprocessableEntityError) as exc_info: admin_secrets.create_secret( - body=PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")), + body=PlatformSecretCreateRequest.model_construct( + name=invalid_name, value=SecretStr("test-value"), description=None + ), workspace="default", ) diff --git a/services/core/secrets/tests/test_secrets.py b/services/core/secrets/tests/test_secrets.py index ef3aed559d..6b33b264ca 100644 --- a/services/core/secrets/tests/test_secrets.py +++ b/services/core/secrets/tests/test_secrets.py @@ -65,18 +65,26 @@ async def test_create_secret_with_empty_value(test_client): assert response.status_code == 422 -@pytest.mark.parametrize("invalid_name", ["bad name", "bad/name", "no!way", "a@b"]) -async def test_create_secret_with_invalid_name_returns_friendly_error(test_client, invalid_name): - """Verify that names with disallowed characters return a 422 with a readable message.""" +@pytest.mark.parametrize( + "invalid_name", + ["bad name", "bad/name", "no!way", "MySecret", "1secret", "my--secret", "secret-", "a" * 64], +) +async def test_create_secret_with_invalid_name_is_rejected(test_client, invalid_name): + """Names the entity store would reject must fail at the API boundary, not downstream.""" response = test_client.post( "/apis/secrets/v2/workspaces/default/secrets", json={"name": invalid_name, "value": "x"}, ) assert response.status_code == 422 - detail = response.json()["detail"] - msg = detail[0]["msg"] - assert "Invalid secret name" in msg - assert invalid_name in msg + + +@pytest.mark.parametrize("valid_name", ["hf-token", "a@b", "model.v1", "svc_key", "ab"]) +async def test_create_secret_accepts_entity_store_names(test_client, valid_name): + response = test_client.post( + "/apis/secrets/v2/workspaces/default/secrets", + json={"name": valid_name, "value": "x"}, + ) + assert response.status_code == 201 async def test_create_and_delete_secret(test_client): From ac0f2dd36c0cc69c551ea6721be04396d5abcd73 Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 29 Jul 2026 14:41:54 -0700 Subject: [PATCH 02/12] refactor(api): collapse the mirrored entity name constants [ASTD-349] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NAME_PATTERN was copy-pasted verbatim in four Python modules — nmp_common, plus files/types.py, secrets/types.py, and jobs/spec.py inside nemo_platform_plugin. Each carried a comment explaining it was inlined to avoid an nmp_common dependency, and jobs/spec.py cited files/types.py as the precedent, so the duplication was self-propagating. The dependency only runs one way: nmp_common depends on nemo-platform-plugin, so the plugin cannot import nmp_common without a cycle. That makes the plugin the correct home. New leaf module entity_naming.py holds the single definition and imports nothing, so modules that need to stay leaf nodes still can. nmp_common re-exports it, leaving every existing constants.NAME_PATTERN call site untouched. Pure refactor: regenerating the OpenAPI spec produces no diff. Signed-off-by: mschwab --- .../src/nemo_platform_plugin/entity_naming.py | 33 +++++++++++++++++++ .../src/nemo_platform_plugin/files/types.py | 11 +------ .../src/nemo_platform_plugin/jobs/spec.py | 12 +------ .../src/nemo_platform_plugin/secrets/types.py | 19 +++-------- .../src/nmp/common/entities/constants.py | 25 ++++++-------- 5 files changed, 49 insertions(+), 51 deletions(-) create mode 100644 packages/nemo_platform_plugin/src/nemo_platform_plugin/entity_naming.py diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/entity_naming.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/entity_naming.py new file mode 100644 index 0000000000..65bcf8883f --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/entity_naming.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The single definition of the entity name rule. + +This lives in ``nemo_platform_plugin`` rather than ``nmp_common`` because the +dependency runs that way — ``nmp_common`` depends on this package, so anything +here is importable from both sides. ``nmp.common.entities.constants`` re-exports +these names. + +Imports nothing, so modules that need to stay leaf nodes can still use it. + +Any Pydantic model declaring ``pattern=NAME_PATTERN`` must also set +``model_config = ConfigDict(regex_engine="python-re")``; the pattern uses +lookaround, which Pydantic's default Rust engine rejects. +""" + +# RFC 1035 compliant pattern with temporary support for special characters. +# Allows lowercase letters, digits, hyphens, and temporarily: @, ., +, _ +# - Must start with a lowercase letter [a-z] +# - Length: 2-63 characters +# - No consecutive hyphens (--) +# - Must not end with a hyphen +# TODO(#3530): Remove @, ., +, _ once versioning is implemented and predefined target names (e.g., llama-3.2-3b-instruct@v1.0.0+A100) are updated. +NAME_PATTERN = r"^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? Date: Wed, 29 Jul 2026 15:03:15 -0700 Subject: [PATCH 03/12] refactor(api): trim comments on entity name validation [ASTD-349] Signed-off-by: mschwab --- .../src/nemo_platform_plugin/entity_naming.py | 14 +++----------- .../src/nmp/common/entities/constants.py | 2 -- .../tests/integration/test_secrets_with_auth.py | 3 +-- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/entity_naming.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/entity_naming.py index 65bcf8883f..a3cbcd41c1 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/entity_naming.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/entity_naming.py @@ -1,18 +1,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""The single definition of the entity name rule. +"""Single definition of the entity name rule; ``nmp.common`` re-exports it. -This lives in ``nemo_platform_plugin`` rather than ``nmp_common`` because the -dependency runs that way — ``nmp_common`` depends on this package, so anything -here is importable from both sides. ``nmp.common.entities.constants`` re-exports -these names. - -Imports nothing, so modules that need to stay leaf nodes can still use it. - -Any Pydantic model declaring ``pattern=NAME_PATTERN`` must also set -``model_config = ConfigDict(regex_engine="python-re")``; the pattern uses -lookaround, which Pydantic's default Rust engine rejects. +Lives here because ``nmp_common`` depends on this package, not the reverse. +Models using ``pattern=NAME_PATTERN`` need ``regex_engine="python-re"``. """ # RFC 1035 compliant pattern with temporary support for special characters. diff --git a/packages/nmp_common/src/nmp/common/entities/constants.py b/packages/nmp_common/src/nmp/common/entities/constants.py index eae1113835..cfa93945fe 100644 --- a/packages/nmp_common/src/nmp/common/entities/constants.py +++ b/packages/nmp_common/src/nmp/common/entities/constants.py @@ -3,8 +3,6 @@ """Constants for entity validation.""" -# Defined in nemo_platform_plugin so plugins can reach it without depending on -# nmp_common, which would be circular. Re-exported here for existing callers. from nemo_platform_plugin.entity_naming import ( NAME_MAX_LENGTH as NAME_MAX_LENGTH, ) diff --git a/services/core/secrets/tests/integration/test_secrets_with_auth.py b/services/core/secrets/tests/integration/test_secrets_with_auth.py index 58c04fc908..e79a8ef353 100644 --- a/services/core/secrets/tests/integration/test_secrets_with_auth.py +++ b/services/core/secrets/tests/integration/test_secrets_with_auth.py @@ -1001,8 +1001,7 @@ def test_create_secret_with_uppercase_returns_422(self, sdk: NeMoPlatform): PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")) assert "should match pattern" in str(local_exc.value).lower() - # model_construct skips validation so the bad name still reaches the server, - # which is where the 500-vs-422 regression lived. + # model_construct skips validation so the name still reaches the server. with pytest.raises(ClientUnprocessableEntityError) as exc_info: admin_secrets.create_secret( body=PlatformSecretCreateRequest.model_construct( From 8809843e785fdc9ae1744257c127674d4a66a703 Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 29 Jul 2026 15:23:36 -0700 Subject: [PATCH 04/12] fix(api): document the special characters NAME_PATTERN accepts [ASTD-349] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NAME_PATTERN_DESCRIPTION claimed names may contain "only lowercase letters, digits, and hyphens", but the pattern has allowed @, ., + and _ since it was written — see the TODO(#3530) directly above it. The spec therefore told SDK consumers a name like llama-3.2-3b@v1.0.0 was invalid when the service accepts it, and contradicted Studio's own help text, which lists the special characters correctly. All nine name fields in the spec now carry wording that matches the nine patterns beside them. Reported by CodeRabbit on #978. Signed-off-by: mschwab --- openapi/ga/individual/platform.openapi.yaml | 28 ++++++++++--------- openapi/ga/openapi.yaml | 28 ++++++++++--------- openapi/openapi.yaml | 28 ++++++++++--------- .../src/nemo_platform_plugin/entity_naming.py | 2 +- 4 files changed, 46 insertions(+), 40 deletions(-) diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index 38190d9fef..3cc37b00d5 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -9420,8 +9420,8 @@ components: pattern: ^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? Date: Thu, 30 Jul 2026 00:07:11 -0700 Subject: [PATCH 05/12] chore(sdk): regenerate SDK and CLI for the name description [ASTD-349] Picks up the NAME_PATTERN_DESCRIPTION change across the vendored Python SDK, the generated CLI commands, and the CLI reference docs. plugins/nemo-customizer/openapi/openapi.yaml is deliberately excluded: regenerating it locally drops the whole rl/jobs surface, which comes from an optional dependency that isn't installed here. Signed-off-by: mschwab --- docs/cli/reference.mdx | 6 +-- .../cli/commands/api/files/filesets.py | 4 +- .../cli/commands/api/inference/providers.py | 4 +- .../cli/commands/api/projects.py | 4 +- .../cli/commands/api/workspaces/__init__.py | 4 +- .../nemo-platform/.nmpcontext/openapi.yaml | 45 +++++++++++-------- .../cli/commands/api/files/filesets.py | 4 +- .../cli/commands/api/inference/providers.py | 4 +- .../cli/commands/api/projects.py | 4 +- .../cli/commands/api/workspaces/__init__.py | 4 +- .../resources/entities/entities.py | 14 +++--- .../nemo_platform/resources/files/filesets.py | 10 +++-- .../resources/inference/providers.py | 10 +++-- .../resources/inference/virtual_models.py | 6 +-- .../resources/projects/projects.py | 8 ++-- .../resources/secrets/secrets.py | 10 +++-- .../resources/workspaces/workspaces.py | 8 ++-- .../types/entities/entity_create_params.py | 4 +- .../entity_update_entity_by_name_params.py | 4 +- .../types/files/fileset_create_params.py | 5 ++- .../types/inference/provider_create_params.py | 5 ++- .../types/jobs/platform_job_step_spec.py | 4 +- .../jobs/platform_job_step_spec_param.py | 4 +- .../types/projects/project_create_params.py | 4 +- .../types/secrets/secret_create_params.py | 5 ++- .../workspaces/workspace_create_params.py | 4 +- 26 files changed, 103 insertions(+), 85 deletions(-) diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 52ace1f5cb..8302ed1c5b 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -1100,7 +1100,7 @@ nemo files filesets create [OPTIONS] [NAME] **Arguments:** -* ``: The name of the fileset. Allowed characters: letters (a-z, A-Z), digits (0-9), underscores, hyphens, and dots. +* ``: The name of the fileset. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). **Options:** @@ -3144,7 +3144,7 @@ nemo inference providers create [OPTIONS] [NAME] **Arguments:** -* ``: Name of the model provider. Allowed characters: letters (a-z, A-Z), digits (0-9), underscores, hyphens, and dots. +* ``: Name of the model provider. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). **Options:** @@ -5066,7 +5066,7 @@ nemo workspaces create [OPTIONS] [NAME] **Arguments:** -* ``: Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, and hyphens (no consecutive hyphens, cannot end with a hyphen). +* ``: Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). **Options:** diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/files/filesets.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/files/filesets.py index 9ea72faeeb..14bd05b535 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/files/filesets.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/files/filesets.py @@ -34,7 +34,7 @@ def create_filesets( name: Annotated[ str | None, typer.Argument( - help="The name of the fileset. Allowed characters: letters (a-z, A-Z), digits (0-9), underscores, hyphens, and dots. (required)" + help="The name of the fileset. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \\__ (no consecutive hyphens, cannot end with a hyphen). (required)" ), ] = None, workspace: Annotated[str | None, typer.Option("--workspace")] = None, @@ -127,7 +127,7 @@ def create_filesets( ["name"], "files filesets create", { - "name": "The name of the fileset. Allowed characters: letters (a-z, A-Z), digits (0-9), underscores, hyphens, and dots. (required)", + "name": "The name of the fileset. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). (required)", }, ) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/inference/providers.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/inference/providers.py index 7cd01b5937..5a9cee2049 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/inference/providers.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/inference/providers.py @@ -34,7 +34,7 @@ def create_providers( name: Annotated[ str | None, typer.Argument( - help="Name of the model provider. Allowed characters: letters (a-z, A-Z), digits (0-9), underscores, hyphens, and dots. (required)" + help="Name of the model provider. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \\__ (no consecutive hyphens, cannot end with a hyphen). (required)" ), ] = None, workspace: Annotated[str | None, typer.Option("--workspace")] = None, @@ -179,7 +179,7 @@ def create_providers( "inference providers create", { "host_url": "The network endpoint URL for the model provider (required)", - "name": "Name of the model provider. Allowed characters: letters (a-z, A-Z), digits (0-9), underscores, hyphens, and dots. (required)", + "name": "Name of the model provider. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). (required)", }, ) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/projects.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/projects.py index d7e2623feb..482c1d1892 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/projects.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/projects.py @@ -34,7 +34,7 @@ def create_projects( name: Annotated[ str | None, typer.Argument( - help="Project name (unique within workspace). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, and hyphens (no consecutive hyphens, cannot end with a hyphen). (required)" + help="Project name (unique within workspace). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \\__ (no consecutive hyphens, cannot end with a hyphen). (required)" ), ] = None, workspace: Annotated[str | None, typer.Option("--workspace")] = None, @@ -95,7 +95,7 @@ def create_projects( ["name"], "projects create", { - "name": "Project name (unique within workspace). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, and hyphens (no consecutive hyphens, cannot end with a hyphen). (required)", + "name": "Project name (unique within workspace). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). (required)", }, ) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/workspaces/__init__.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/workspaces/__init__.py index e0a5be7791..d83f7e0335 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/workspaces/__init__.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/workspaces/__init__.py @@ -41,7 +41,7 @@ def create_workspaces( name: Annotated[ str | None, typer.Argument( - help="Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, and hyphens (no consecutive hyphens, cannot end with a hyphen). (required)" + help="Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \\__ (no consecutive hyphens, cannot end with a hyphen). (required)" ), ] = None, wait_role_propagation: Annotated[ @@ -112,7 +112,7 @@ def create_workspaces( ["name"], "workspaces create", { - "name": "Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, and hyphens (no consecutive hyphens, cannot end with a hyphen). (required)", + "name": "Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). (required)", }, ) diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index 234a7c54ae..3cc37b00d5 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -9416,11 +9416,12 @@ components: properties: name: type: string - maxLength: 255 - pattern: ^[\w\-.]+$ + maxLength: 63 + pattern: ^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? Date: Thu, 30 Jul 2026 01:23:21 -0700 Subject: [PATCH 06/12] fix(api): reword the entity name description [ASTD-349] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with the previous wording, "contain only lowercase letters, digits, hyphens, and @ . + _". The trailing underscore is markdown-escaped downstream, so the CLI reference rendered "@ . + \__". Dropping the literal characters removes the artifact. Second, @, + and _ are slated for removal (TODO(#3530)), and advertising them invites adoption ahead of that migration — mckornfield's point on this PR. Dots are not in the same category: llama-3.1-8b is the example in CreateModelProviderRequest and dotted versions are how model names are written, so those stay documented. "use" rather than "contain only" keeps this from being a false claim about what the service rejects; the machine-readable pattern remains the contract. Signed-off-by: mschwab --- openapi/ga/individual/platform.openapi.yaml | 44 +++++++++---------- openapi/ga/openapi.yaml | 44 +++++++++---------- openapi/openapi.yaml | 44 +++++++++---------- .../src/nemo_platform_plugin/entity_naming.py | 2 +- 4 files changed, 64 insertions(+), 70 deletions(-) diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index 3cc37b00d5..359cbae96a 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -9420,8 +9420,8 @@ components: pattern: ^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? Date: Thu, 30 Jul 2026 07:05:52 -0700 Subject: [PATCH 07/12] chore(sdk): regenerate SDK and CLI for the reworded description [ASTD-349] Propagates the NAME_PATTERN_DESCRIPTION rewording through the vendored Python SDK, the generated CLI commands, and the CLI reference docs. Also clears the "@ . + \__" escaping artifact the previous wording left in docs/cli/reference.mdx. Signed-off-by: mschwab --- docs/cli/reference.mdx | 6 +-- .../cli/commands/api/files/filesets.py | 4 +- .../cli/commands/api/inference/providers.py | 4 +- .../cli/commands/api/projects.py | 4 +- .../cli/commands/api/workspaces/__init__.py | 4 +- .../nemo-platform/.nmpcontext/openapi.yaml | 44 +++++++++---------- .../cli/commands/api/files/filesets.py | 4 +- .../cli/commands/api/inference/providers.py | 4 +- .../cli/commands/api/projects.py | 4 +- .../cli/commands/api/workspaces/__init__.py | 4 +- .../resources/entities/entities.py | 18 ++++---- .../nemo_platform/resources/files/filesets.py | 8 ++-- .../resources/inference/providers.py | 8 ++-- .../resources/projects/projects.py | 8 ++-- .../resources/secrets/secrets.py | 8 ++-- .../resources/workspaces/workspaces.py | 8 ++-- .../types/entities/entity_create_params.py | 6 +-- .../entity_update_entity_by_name_params.py | 6 +-- .../types/files/fileset_create_params.py | 6 +-- .../types/inference/provider_create_params.py | 6 +-- .../types/jobs/platform_job_step_spec.py | 4 +- .../jobs/platform_job_step_spec_param.py | 4 +- .../types/projects/project_create_params.py | 6 +-- .../types/secrets/secret_create_params.py | 6 +-- .../workspaces/workspace_create_params.py | 6 +-- 25 files changed, 93 insertions(+), 97 deletions(-) diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 8302ed1c5b..ac3acaa3fa 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -1100,7 +1100,7 @@ nemo files filesets create [OPTIONS] [NAME] **Arguments:** -* ``: The name of the fileset. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). +* ``: The name of the fileset. Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). **Options:** @@ -3144,7 +3144,7 @@ nemo inference providers create [OPTIONS] [NAME] **Arguments:** -* ``: Name of the model provider. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). +* ``: Name of the model provider. Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). **Options:** @@ -5066,7 +5066,7 @@ nemo workspaces create [OPTIONS] [NAME] **Arguments:** -* ``: Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). +* ``: Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). **Options:** diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/files/filesets.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/files/filesets.py index 14bd05b535..b11d28b5aa 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/files/filesets.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/files/filesets.py @@ -34,7 +34,7 @@ def create_filesets( name: Annotated[ str | None, typer.Argument( - help="The name of the fileset. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \\__ (no consecutive hyphens, cannot end with a hyphen). (required)" + help="The name of the fileset. Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). (required)" ), ] = None, workspace: Annotated[str | None, typer.Option("--workspace")] = None, @@ -127,7 +127,7 @@ def create_filesets( ["name"], "files filesets create", { - "name": "The name of the fileset. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). (required)", + "name": "The name of the fileset. Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). (required)", }, ) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/inference/providers.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/inference/providers.py index 5a9cee2049..44c6e126df 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/inference/providers.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/inference/providers.py @@ -34,7 +34,7 @@ def create_providers( name: Annotated[ str | None, typer.Argument( - help="Name of the model provider. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \\__ (no consecutive hyphens, cannot end with a hyphen). (required)" + help="Name of the model provider. Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). (required)" ), ] = None, workspace: Annotated[str | None, typer.Option("--workspace")] = None, @@ -179,7 +179,7 @@ def create_providers( "inference providers create", { "host_url": "The network endpoint URL for the model provider (required)", - "name": "Name of the model provider. Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). (required)", + "name": "Name of the model provider. Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). (required)", }, ) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/projects.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/projects.py index 482c1d1892..073d10a358 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/projects.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/projects.py @@ -34,7 +34,7 @@ def create_projects( name: Annotated[ str | None, typer.Argument( - help="Project name (unique within workspace). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \\__ (no consecutive hyphens, cannot end with a hyphen). (required)" + help="Project name (unique within workspace). Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). (required)" ), ] = None, workspace: Annotated[str | None, typer.Option("--workspace")] = None, @@ -95,7 +95,7 @@ def create_projects( ["name"], "projects create", { - "name": "Project name (unique within workspace). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). (required)", + "name": "Project name (unique within workspace). Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). (required)", }, ) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/workspaces/__init__.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/workspaces/__init__.py index d83f7e0335..30d201254d 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/workspaces/__init__.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/api/workspaces/__init__.py @@ -41,7 +41,7 @@ def create_workspaces( name: Annotated[ str | None, typer.Argument( - help="Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \\__ (no consecutive hyphens, cannot end with a hyphen). (required)" + help="Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). (required)" ), ] = None, wait_role_propagation: Annotated[ @@ -112,7 +112,7 @@ def create_workspaces( ["name"], "workspaces create", { - "name": "Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, hyphens, and @ . + \__ (no consecutive hyphens, cannot end with a hyphen). (required)", + "name": "Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). (required)", }, ) diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index 3cc37b00d5..359cbae96a 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -9420,8 +9420,8 @@ components: pattern: ^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? Date: Mon, 27 Jul 2026 11:42:40 -0700 Subject: [PATCH 08/12] fix(studio): validate entity names against the entity-store pattern Forms validated names against the SDK-generated regex `^[\w\-.]+$`, which is what the service DTOs advertise in the OpenAPI spec. The entity store actually enforces a stricter RFC-1035-ish pattern downstream, so names like `Sparl` passed client-side validation and then 422'd on submit. Add `entityName` to common: mirrors `NAME_PATTERN` from packages/nmp_common/src/nmp/common/entities/constants.py and reports the specific rule a value breaks, with a repaired-name suggestion where one exists (`Sparl` -> `Name must be lowercase. Try "sparl".`). Adopt it in the inference provider, secret, and fileset forms, which each carried their own copy of the pattern or their own wording of the same rule. The inference provider form also pre-empts the duplicate-name 409 using the provider list it already fetches. The underlying fix belongs in the OpenAPI spec: once the request DTOs declare NAME_PATTERN, the generated zod is correct and this mirror can go away. Signed-off-by: mschwab --- .../common/src/utils/entityName.test.ts | 105 ++++++++++++++++++ web/packages/common/src/utils/entityName.ts | 100 +++++++++++++++++ .../common/src/utils/filesetName.test.ts | 11 +- web/packages/common/src/utils/filesetName.ts | 53 +-------- .../FilesetCreateModal/constants.ts | 14 +-- .../src/routes/FilesetNewRoute/constants.ts | 5 - .../src/routes/FilesetNewRoute/index.test.tsx | 4 +- .../src/routes/FilesetNewRoute/types.ts | 16 +-- .../index.test.tsx | 54 ++++++++- .../index.tsx | 24 ++-- .../CreateSecretModal/constants.ts | 8 -- .../CreateSecretModal/index.tsx | 9 +- .../components/SubmitEvaluationModal.tsx | 15 +-- 13 files changed, 292 insertions(+), 126 deletions(-) create mode 100644 web/packages/common/src/utils/entityName.test.ts create mode 100644 web/packages/common/src/utils/entityName.ts delete mode 100644 web/packages/studio/src/routes/SecretsListRoute/CreateSecretModal/constants.ts diff --git a/web/packages/common/src/utils/entityName.test.ts b/web/packages/common/src/utils/entityName.test.ts new file mode 100644 index 0000000000..f29c06a874 --- /dev/null +++ b/web/packages/common/src/utils/entityName.test.ts @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + ENTITY_NAME_REGEXP, + entityNameSchema, + getEntityNameError, + sanitizeEntityName, + toValidEntityName, +} from '@nemo/common/src/utils/entityName'; + +describe('getEntityNameError', () => { + it.each(['sparl', 'a1', 'my-provider', 'llama-3.2-3b-instruct@v1.0.0+A100'.toLowerCase(), 'a_b'])( + 'accepts %s', + (value) => { + expect(getEntityNameError(value)).toBeUndefined(); + } + ); + + it('reports a missing value', () => { + expect(getEntityNameError('')).toBe('Name is required.'); + }); + + it('reports uppercase with a lowercased suggestion', () => { + expect(getEntityNameError('Sparl')).toBe('Name must be lowercase. Try "sparl".'); + }); + + it('lists the disallowed characters', () => { + expect(getEntityNameError('invalid name!')).toContain('cannot contain spaces, "!"'); + }); + + it('reports a leading non-letter', () => { + expect(getEntityNameError('1provider')).toBe( + 'Name must start with a lowercase letter. Try "provider".' + ); + }); + + it('reports consecutive hyphens', () => { + expect(getEntityNameError('my--provider')).toBe( + 'Name cannot contain consecutive hyphens. Try "my-provider".' + ); + }); + + it('reports a trailing hyphen', () => { + expect(getEntityNameError('myprovider-')).toBe( + 'Name cannot end with a hyphen. Try "myprovider".' + ); + }); + + it('reports too-short values without a bogus suggestion', () => { + expect(getEntityNameError('a')).toBe('Name must be at least 2 characters.'); + }); + + it('reports too-long values with the current length', () => { + expect(getEntityNameError('a'.repeat(64))).toContain( + 'Name must be 63 characters or fewer (currently 64).' + ); + }); + + it('uses the supplied label', () => { + expect(getEntityNameError('', 'Provider name')).toBe('Provider name is required.'); + }); +}); + +describe('sanitizeEntityName', () => { + it.each([ + 'Qwen3.6-35B-A3B-MTP-GGUF', + 'mistralai/Mistral-7B-Instruct-v0.3', + 'hello world', + ' leading-trailing ', + '123-starts-with-digit', + 'has--double--dashes', + 'ends-with-dash-', + 'x'.repeat(200), + ])('produces a valid name for %s', (input) => { + const result = sanitizeEntityName(input); + expect(result).toBeDefined(); + expect(ENTITY_NAME_REGEXP.test(result as string)).toBe(true); + }); + + it('returns undefined when nothing valid remains', () => { + expect(sanitizeEntityName('!!!')).toBeUndefined(); + expect(sanitizeEntityName('')).toBeUndefined(); + }); +}); + +describe('toValidEntityName', () => { + it('falls back when nothing valid remains', () => { + expect(toValidEntityName('!!!', 'provider')).toBe('provider'); + }); +}); + +describe('entityNameSchema', () => { + it('surfaces the rule-specific message', () => { + const result = entityNameSchema('Provider name').safeParse('Sparl'); + expect(result.success).toBe(false); + expect(result.success === false && result.error.issues[0].message).toBe( + 'Provider name must be lowercase. Try "sparl".' + ); + }); + + it('passes valid names through', () => { + expect(entityNameSchema().safeParse('sparl')).toMatchObject({ success: true, data: 'sparl' }); + }); +}); diff --git a/web/packages/common/src/utils/entityName.ts b/web/packages/common/src/utils/entityName.ts new file mode 100644 index 0000000000..be8958e84b --- /dev/null +++ b/web/packages/common/src/utils/entityName.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { z } from 'zod'; + +/** + * Mirrors the entity store's RFC-1035-ish name pattern from + * `packages/nmp_common/src/nmp/common/entities/constants.py` (`NAME_PATTERN`). + * + * Several service DTOs advertise a looser pattern (`^[\w\-.]+$`, max 255), which + * is what OpenAPI/orval pulls into the generated zod schemas. The stricter + * pattern is only enforced downstream by the entity store, so validating against + * the generated schema lets invalid names through to a confusing 422. + */ +export const ENTITY_NAME_REGEXP = /^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(?= ENTITY_NAME_MIN_LENGTH ? sanitized : undefined; +} + +/** Rewrite input to satisfy `ENTITY_NAME_REGEXP`, falling back when nothing valid remains. */ +export function toValidEntityName(input: string, fallback: string): string { + return sanitizeEntityName(input) ?? fallback; +} + +function listInvalidChars(value: string): string[] { + const found = value.replace(/[A-Z]/g, '').match(INVALID_BODY_CHAR) ?? []; + return [...new Set(found)].map((char) => (char === ' ' ? 'spaces' : `"${char}"`)); +} + +/** + * First specific rule the value breaks, phrased for a form field, or `undefined` + * when the value is valid. + */ +export function getEntityNameError(value: string, label = 'Name'): string | undefined { + if (!value) return `${label} is required.`; + if (ENTITY_NAME_REGEXP.test(value)) return undefined; + + const suggestion = sanitizeEntityName(value); + const hint = suggestion && suggestion !== value ? ` Try "${suggestion}".` : ''; + + if (value.length > ENTITY_NAME_MAX_LENGTH) { + return `${label} must be ${ENTITY_NAME_MAX_LENGTH} characters or fewer (currently ${value.length}).${hint}`; + } + if (value.length < ENTITY_NAME_MIN_LENGTH) { + return `${label} must be at least ${ENTITY_NAME_MIN_LENGTH} characters.`; + } + if (/[A-Z]/.test(value)) { + return `${label} must be lowercase.${hint}`; + } + + const invalidChars = listInvalidChars(value); + if (invalidChars.length > 0) { + return `${label} cannot contain ${invalidChars.join(', ')}. Use lowercase letters, numbers, and - _ . @ + only.${hint}`; + } + if (!/^[a-z]/.test(value)) { + return `${label} must start with a lowercase letter.${hint}`; + } + if (value.includes('--')) { + return `${label} cannot contain consecutive hyphens.${hint}`; + } + if (value.endsWith('-')) { + return `${label} cannot end with a hyphen.${hint}`; + } + + return `${label} is invalid. ${ENTITY_NAME_HELP}${hint}`; +} + +/** Zod string schema enforcing `ENTITY_NAME_REGEXP` with per-rule error messages. */ +export function entityNameSchema(label = 'Name') { + return z.string().superRefine((value, ctx) => { + const message = getEntityNameError(value, label); + if (message) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message }); + } + }); +} diff --git a/web/packages/common/src/utils/filesetName.test.ts b/web/packages/common/src/utils/filesetName.test.ts index 2e20ad22c2..1ad705fad5 100644 --- a/web/packages/common/src/utils/filesetName.test.ts +++ b/web/packages/common/src/utils/filesetName.test.ts @@ -1,14 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - FILESET_NAME_MAX_LENGTH, - FILESET_NAME_REGEXP, - toValidFilesetName, -} from '@nemo/common/src/utils/filesetName'; +import { ENTITY_NAME_REGEXP } from '@nemo/common/src/utils/entityName'; +import { FILESET_NAME_MAX_LENGTH, toValidFilesetName } from '@nemo/common/src/utils/filesetName'; describe('toValidFilesetName', () => { - describe('produces output that satisfies FILESET_NAME_REGEXP', () => { + describe('produces output that satisfies ENTITY_NAME_REGEXP', () => { const inputs = [ 'Qwen3.6-35B-A3B-MTP-GGUF', // HF slug with uppercase 'mistralai/Mistral-7B-Instruct-v0.3', @@ -25,7 +22,7 @@ describe('toValidFilesetName', () => { ]; it.each(inputs)('%s -> matches regex', (input) => { const out = toValidFilesetName(input); - expect(out).toMatch(FILESET_NAME_REGEXP); + expect(out).toMatch(ENTITY_NAME_REGEXP); }); }); diff --git a/web/packages/common/src/utils/filesetName.ts b/web/packages/common/src/utils/filesetName.ts index 785a04f455..a21b14d3ea 100644 --- a/web/packages/common/src/utils/filesetName.ts +++ b/web/packages/common/src/utils/filesetName.ts @@ -1,59 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -/** - * Mirrors the entity store's RFC-1035-ish name pattern from - * `packages/nmp_common/src/nmp/common/entities/constants.py` (`NAME_PATTERN`): - * - * ^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? { await user.type(nameInput, 'tiny-gpt2-A'); await user.tab(); - expect(await screen.findByText(/must start with a lowercase letter/i)).toBeInTheDocument(); + expect( + await screen.findByText(/Name must be lowercase\. Try "tiny-gpt2-a"\./) + ).toBeInTheDocument(); }); it('shows inline validation error when fileset name starts with a digit', async () => { diff --git a/web/packages/studio/src/routes/FilesetNewRoute/types.ts b/web/packages/studio/src/routes/FilesetNewRoute/types.ts index b370bce3bb..932382e51b 100644 --- a/web/packages/studio/src/routes/FilesetNewRoute/types.ts +++ b/web/packages/studio/src/routes/FilesetNewRoute/types.ts @@ -1,15 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { FILESET_NAME_MAX_LENGTH, FILESET_NAME_REGEXP } from '@nemo/common/src/utils/filesetName'; +import { entityNameSchema } from '@nemo/common/src/utils/entityName'; import { FilesetPurpose } from '@nemo/sdk/generated/platform/schema'; import { FilesCreateFilesetBody } from '@nemo/sdk/generated/platform/zod/files'; -import { - DATASET_NAME_PATTERN_MESSAGE, - DATASET_NAME_REQUIRED_MESSAGE, - DATASET_TYPE_CUSTOM, - DATASET_TYPE_SAMPLE, -} from '@studio/routes/FilesetNewRoute/constants'; +import { DATASET_TYPE_CUSTOM, DATASET_TYPE_SAMPLE } from '@studio/routes/FilesetNewRoute/constants'; import { z } from 'zod'; /** @@ -19,12 +14,7 @@ import { z } from 'zod'; * strict pattern here so the user sees a useful inline error instead of a 422. */ export const DatasetCreateFilesetFormSchema = FilesCreateFilesetBody.extend({ - name: z - .string() - .trim() - .min(1, DATASET_NAME_REQUIRED_MESSAGE) - .max(FILESET_NAME_MAX_LENGTH) - .regex(FILESET_NAME_REGEXP, DATASET_NAME_PATTERN_MESSAGE), + name: z.string().trim().pipe(entityNameSchema('Name')), purpose: z.nativeEnum(FilesetPurpose), }); diff --git a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx index 5cfaf44042..4d5f2e0fea 100644 --- a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx +++ b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx @@ -115,17 +115,65 @@ describe('CreateInferenceProviderSidePanel', () => { }); describe('Form validation', () => { - it('shows validation error for invalid name characters', async () => { + async function typeName(value: string) { const user = userEvent.setup(); render(); const dialog = await screen.findByTestId('nv-side-panel-content'); await openModelProviderSelect(user); await user.click(screen.getByRole('option', { name: /openai compatible endpoint/i })); const nameInput = within(dialog).getByRole('textbox', { name: 'Name' }); - fireEvent.change(nameInput, { target: { value: 'invalid name!' } }); + fireEvent.change(nameInput, { target: { value } }); fireEvent.blur(nameInput); + } + + it('shows validation error for invalid name characters', async () => { + await typeName('invalid name!'); + expect(await screen.findByText(/cannot contain spaces, "!"/)).toBeInTheDocument(); + }); + + it('shows a lowercase error with a suggestion for a capitalized name', async () => { + await typeName('Sparl'); + expect(await screen.findByText(/Name must be lowercase\. Try "sparl"\./)).toBeInTheDocument(); + }); + + it('shows a specific error for a name that does not start with a letter', async () => { + await typeName('1provider'); + expect(await screen.findByText(/must start with a lowercase letter/)).toBeInTheDocument(); + }); + + it('shows a specific error for consecutive hyphens', async () => { + await typeName('my--provider'); + expect(await screen.findByText(/cannot contain consecutive hyphens/)).toBeInTheDocument(); + }); + + it('shows a specific error for a trailing hyphen', async () => { + await typeName('myprovider-'); + expect(await screen.findByText(/cannot end with a hyphen/)).toBeInTheDocument(); + }); + + it('shows a specific error for a name that is too long', async () => { + await typeName('a'.repeat(64)); + expect(await screen.findByText(/must be 63 characters or fewer/)).toBeInTheDocument(); + }); + + it('shows an error when the name is already taken', async () => { + server.use( + http.get(`${PLATFORM_BASE_URL}/apis/models/v2/workspaces/:workspace/providers`, () => + HttpResponse.json({ + data: [{ name: 'taken', host_url: 'https://api.example.com/v1' }], + pagination: { + page: 1, + page_size: 100, + current_page_size: 1, + total_pages: 1, + total_results: 1, + }, + }) + ) + ); + await typeName('taken'); expect( - await screen.findByText(/Use only letters, numbers, hyphens, underscores, or dots/) + await screen.findByText(/A provider with this name already exists/) ).toBeInTheDocument(); }); diff --git a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx index b2e0feb709..9063dab461 100644 --- a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx +++ b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx @@ -14,12 +14,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; import { LoadingButton } from '@nemo/common/src/components/LoadingButton'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { ENTITY_NAME_HELP, entityNameSchema } from '@nemo/common/src/utils/entityName'; import { getModelsListProvidersQueryKey, useModelsCreateProvider, useModelsListProviders, } from '@nemo/sdk/generated/platform/api'; -import { modelsCreateProviderBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/model-providers'; import { Button, Flex, FormField, SidePanel, Stack, Text } from '@nvidia/foundations-react-core'; import { getErrorMessage } from '@studio/api/common/utils'; import { InferenceModelProviderSelect } from '@studio/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/InferenceModelProviderSelect'; @@ -37,14 +37,7 @@ import { z } from 'zod'; const PROVIDERS_PAGE_SIZE = 100; const createProviderFormSchema = z.object({ - name: z - .string() - .min(1, 'Name is required') - .max(255) - .regex( - modelsCreateProviderBodyNameRegExp, - 'Use only letters, numbers, hyphens, underscores, or dots.' - ), + name: entityNameSchema('Name'), host_url: z.string().min(1, 'Host URL is required').url('Enter a valid URL').max(2048), api_key_secret_name: z.string().max(255).optional().or(z.literal('')), }); @@ -130,6 +123,15 @@ export const CreateInferenceProviderSidePanel: FC + createProviderFormSchema.refine((data) => !existingNames.has(data.name), { + path: ['name'], + message: 'A provider with this name already exists.', + }), + [existingNames] + ); + const { control, reset: resetForm, @@ -137,7 +139,7 @@ export const CreateInferenceProviderSidePanel: FC diff --git a/web/packages/studio/src/routes/SecretsListRoute/CreateSecretModal/constants.ts b/web/packages/studio/src/routes/SecretsListRoute/CreateSecretModal/constants.ts deleted file mode 100644 index 130098fbc8..0000000000 --- a/web/packages/studio/src/routes/SecretsListRoute/CreateSecretModal/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// TODO get from sdk once issue #4082 is resolved -export const SECRET_NAME_REGEXP = new RegExp('^[a-z](?!.*--)[a-z0-9\\-@.+_]{1,62}(? = ({ formFieldProps={{ slotInfo: 'Best practice: Use lowercase letters, numbers, and hyphens only to ensure compatibility with Kubernetes naming conventions.', - slotHelp: SECRET_NAME_HELP, + slotHelp: ENTITY_NAME_HELP, slotError: errors.name?.message, }} /> diff --git a/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal.tsx b/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal.tsx index 2e674eca1e..effab6a3a8 100644 --- a/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal.tsx +++ b/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/components/SubmitEvaluationModal.tsx @@ -9,7 +9,7 @@ import { ControlledTextInput } from '@nemo/common/src/components/form/Controlled import { FormModal, type FormModalProps } from '@nemo/common/src/components/FormModal'; import { getURNFromNamedEntityRef } from '@nemo/common/src/namedEntity'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; -import { FILESET_NAME_REGEXP } from '@nemo/common/src/utils/filesetName'; +import { getEntityNameError } from '@nemo/common/src/utils/entityName'; import { useAgentsListAgents } from '@nemo/sdk/generated/agents/api'; import type { AgentEvaluateJobRequest } from '@nemo/sdk/generated/evaluator/schema'; import { filesDownloadFile, filesListFilesetFiles } from '@nemo/sdk/generated/platform/api'; @@ -38,7 +38,6 @@ import { parsePersistedSpec, type PersistedEvalSpec, } from '@studio/routes/agents/AgentEvaluationsRoute/components/submitEvaluationSpec'; -import { DATASET_NAME_PATTERN_MESSAGE } from '@studio/routes/FilesetNewRoute/constants'; import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { type FC, useEffect, useRef, useState } from 'react'; import { FormProvider, type SubmitHandler, useForm, useWatch } from 'react-hook-form'; @@ -73,17 +72,11 @@ const makeSubmitEvaluationSchema = (requiresJudgeModel: () => boolean) => }); } if (data.mode === MODE_DEFAULT) { - const name = data.newName.trim(); - if (!name) { + const nameError = getEntityNameError(data.newName.trim()); + if (nameError) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Name is required', - path: ['newName'], - }); - } else if (!FILESET_NAME_REGEXP.test(name)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: DATASET_NAME_PATTERN_MESSAGE, + message: nameError, path: ['newName'], }); } From 1d21114f5691dea13951cfe98aa46aee703b57c3 Mon Sep 17 00:00:00 2001 From: mschwab Date: Tue, 28 Jul 2026 13:18:41 -0700 Subject: [PATCH 09/12] refactor(studio): trim comments on entity name validation Signed-off-by: mschwab --- web/packages/common/src/utils/entityName.ts | 17 ++++------------- .../components/FilesetCreateModal/constants.ts | 6 ++---- .../studio/src/routes/FilesetNewRoute/types.ts | 8 ++------ 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/web/packages/common/src/utils/entityName.ts b/web/packages/common/src/utils/entityName.ts index be8958e84b..a35d509591 100644 --- a/web/packages/common/src/utils/entityName.ts +++ b/web/packages/common/src/utils/entityName.ts @@ -3,15 +3,9 @@ import { z } from 'zod'; -/** - * Mirrors the entity store's RFC-1035-ish name pattern from - * `packages/nmp_common/src/nmp/common/entities/constants.py` (`NAME_PATTERN`). - * - * Several service DTOs advertise a looser pattern (`^[\w\-.]+$`, max 255), which - * is what OpenAPI/orval pulls into the generated zod schemas. The stricter - * pattern is only enforced downstream by the entity store, so validating against - * the generated schema lets invalid names through to a confusing 422. - */ +// Mirrors NAME_PATTERN in packages/nmp_common/src/nmp/common/entities/constants.py. +// The generated zod uses the looser pattern the service DTOs advertise, which lets +// invalid names through to a 422 from the entity store. export const ENTITY_NAME_REGEXP = /^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? (char === ' ' ? 'spaces' : `"${char}"`)); } -/** - * First specific rule the value breaks, phrased for a form field, or `undefined` - * when the value is valid. - */ +/** First rule the value breaks, phrased for a form field, or `undefined` if valid. */ export function getEntityNameError(value: string, label = 'Name'): string | undefined { if (!value) return `${label} is required.`; if (ENTITY_NAME_REGEXP.test(value)) return undefined; diff --git a/web/packages/studio/src/components/FilesetCreateModal/constants.ts b/web/packages/studio/src/components/FilesetCreateModal/constants.ts index 21f925cb35..59ab137075 100644 --- a/web/packages/studio/src/components/FilesetCreateModal/constants.ts +++ b/web/packages/studio/src/components/FilesetCreateModal/constants.ts @@ -13,10 +13,8 @@ export enum StorageMode { export type SupportedPurpose = typeof FilesetPurpose.dataset | typeof FilesetPurpose.model; -// Override the SDK-generated `name` validation. The generated zod uses the -// Files service DTO's loose pattern (`^[\w\-.]+$`, max 255); the entity store -// downstream enforces a stricter RFC-1035-ish pattern. We validate against the -// strict one here so the user sees a useful error instead of a 422 toast. +// Override the SDK-generated `name` validation, which uses a looser pattern than the +// entity store enforces. export const filesetCreateFormSchema = FilesCreateFilesetBody.pick({ name: true, description: true, diff --git a/web/packages/studio/src/routes/FilesetNewRoute/types.ts b/web/packages/studio/src/routes/FilesetNewRoute/types.ts index 932382e51b..0b12919dc9 100644 --- a/web/packages/studio/src/routes/FilesetNewRoute/types.ts +++ b/web/packages/studio/src/routes/FilesetNewRoute/types.ts @@ -7,12 +7,8 @@ import { FilesCreateFilesetBody } from '@nemo/sdk/generated/platform/zod/files'; import { DATASET_TYPE_CUSTOM, DATASET_TYPE_SAMPLE } from '@studio/routes/FilesetNewRoute/constants'; import { z } from 'zod'; -/** - * Override the SDK-generated name validation. The generated zod uses the Files - * service DTO's loose pattern (`^[\w\-.]+$`, max 255); the entity store - * downstream enforces a stricter RFC-1035-ish pattern. Validate against the - * strict pattern here so the user sees a useful inline error instead of a 422. - */ +// Override the SDK-generated name validation, which uses a looser pattern than the +// entity store enforces. export const DatasetCreateFilesetFormSchema = FilesCreateFilesetBody.extend({ name: z.string().trim().pipe(entityNameSchema('Name')), purpose: z.nativeEnum(FilesetPurpose), From 92fadf2f65548d088f8b9ee2d63fb7b9894dcd44 Mon Sep 17 00:00:00 2001 From: mschwab Date: Tue, 28 Jul 2026 17:20:31 -0700 Subject: [PATCH 10/12] fix(studio): block provider submit until the provider list loads The duplicate-name refinement runs against an empty set while the providers query is in flight, so a duplicate could reach the API and come back as a 409. Disable Add Provider until the list resolves. Also give entityNameSchema an explicit return type. Signed-off-by: mschwab --- web/packages/common/src/utils/entityName.ts | 2 +- .../index.test.tsx | 28 ++++++++++++++++++- .../index.tsx | 8 ++++-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/web/packages/common/src/utils/entityName.ts b/web/packages/common/src/utils/entityName.ts index a35d509591..5c3f9b4260 100644 --- a/web/packages/common/src/utils/entityName.ts +++ b/web/packages/common/src/utils/entityName.ts @@ -81,7 +81,7 @@ export function getEntityNameError(value: string, label = 'Name'): string | unde } /** Zod string schema enforcing `ENTITY_NAME_REGEXP` with per-rule error messages. */ -export function entityNameSchema(label = 'Name') { +export function entityNameSchema(label = 'Name'): z.ZodEffects { return z.string().superRefine((value, ctx) => { const message = getEntityNameError(value, label); if (message) { diff --git a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx index 4d5f2e0fea..022f954194 100644 --- a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx +++ b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.test.tsx @@ -16,7 +16,7 @@ import { CreateInferenceProviderSidePanel } from '@studio/routes/InferenceProvid import { render } from '@studio/tests/util/render'; import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; +import { delay, http, HttpResponse } from 'msw'; const mockOnClose = vi.fn(); @@ -177,6 +177,32 @@ describe('CreateInferenceProviderSidePanel', () => { ).toBeInTheDocument(); }); + it('blocks submit until the existing providers have loaded', async () => { + server.use( + http.get( + `${PLATFORM_BASE_URL}/apis/models/v2/workspaces/:workspace/providers`, + async () => { + await delay(200); + return HttpResponse.json({ + data: [{ name: 'taken', host_url: 'https://api.example.com/v1' }], + pagination: { + page: 1, + page_size: 100, + current_page_size: 1, + total_pages: 1, + total_results: 1, + }, + }); + } + ) + ); + render(); + const dialog = await screen.findByTestId('nv-side-panel-content'); + const submit = within(dialog).getByRole('button', { name: 'Add Provider' }); + expect(submit).toBeDisabled(); + await waitFor(() => expect(submit).toBeEnabled()); + }); + it('shows validation error for invalid URL', async () => { const user = userEvent.setup(); render(); diff --git a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx index 9063dab461..1580eff242 100644 --- a/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx +++ b/web/packages/studio/src/routes/InferenceProvidersListRoute/CreateInferenceProviderSidePanel/index.tsx @@ -80,7 +80,7 @@ export const CreateInferenceProviderSidePanel: FC(defaultPreset ?? 'custom'); const [createSecretModalOpen, setCreateSecretModalOpen] = useState(false); - const { data: providersData } = useModelsListProviders( + const { data: providersData, isLoading: isProvidersLoading } = useModelsListProviders( workspace, { page_size: PROVIDERS_PAGE_SIZE }, { query: { enabled: open && !!workspace } } @@ -240,7 +240,11 @@ export const CreateInferenceProviderSidePanel: FC Cancel - + Add Provider From bb7ae8456cc5adb74f33a0dfef9d590ec83e2fce Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 29 Jul 2026 15:06:28 -0700 Subject: [PATCH 11/12] refactor(studio): source the entity name regex from generated zod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #978 makes the create DTOs declare NAME_PATTERN, so the generated zod now carries the strict pattern and entityName.ts no longer has to hand-mirror it. Take ENTITY_NAME_REGEXP from entitiesCreateEntityBodyNameRegExp — the generic entity-store endpoint, not one of the per-resource copies. The max length stays local because the entity-store schema declares no maxLength; a test pins it against the create DTOs that do. That test also asserts the entity, fileset, secret, and model-provider schemas still agree on the pattern, so a future drift between them fails CI. The per-rule error messages and the sanitizer's character classes have no generated equivalent and remain hand-written. Signed-off-by: mschwab --- .../common/src/utils/entityName.test.ts | 29 +++++++++++++++++++ web/packages/common/src/utils/entityName.ts | 7 ++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/web/packages/common/src/utils/entityName.test.ts b/web/packages/common/src/utils/entityName.test.ts index f29c06a874..c94cc88777 100644 --- a/web/packages/common/src/utils/entityName.test.ts +++ b/web/packages/common/src/utils/entityName.test.ts @@ -2,12 +2,41 @@ // SPDX-License-Identifier: Apache-2.0 import { + ENTITY_NAME_MAX_LENGTH, ENTITY_NAME_REGEXP, entityNameSchema, getEntityNameError, sanitizeEntityName, toValidEntityName, } from '@nemo/common/src/utils/entityName'; +import { entitiesCreateEntityBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/entity-store'; +import { + filesCreateFilesetBodyNameMax, + filesCreateFilesetBodyNameRegExp, +} from '@nemo/sdk/generated/platform/zod/files'; +import { modelsCreateProviderBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/model-providers'; +import { + secretsCreateSecretBodyNameMax, + secretsCreateSecretBodyNameRegExp, +} from '@nemo/sdk/generated/platform/zod/secrets'; + +describe('generated schema agreement', () => { + it.each([ + ['entity', entitiesCreateEntityBodyNameRegExp], + ['fileset', filesCreateFilesetBodyNameRegExp], + ['secret', secretsCreateSecretBodyNameRegExp], + ['model provider', modelsCreateProviderBodyNameRegExp], + ])('%s create schema uses the same name pattern', (_name, pattern) => { + expect(pattern.source).toBe(ENTITY_NAME_REGEXP.source); + }); + + it.each([ + ['fileset', filesCreateFilesetBodyNameMax], + ['secret', secretsCreateSecretBodyNameMax], + ])('%s create schema agrees on the max length', (_name, max) => { + expect(max).toBe(ENTITY_NAME_MAX_LENGTH); + }); +}); describe('getEntityNameError', () => { it.each(['sparl', 'a1', 'my-provider', 'llama-3.2-3b-instruct@v1.0.0+A100'.toLowerCase(), 'a_b'])( diff --git a/web/packages/common/src/utils/entityName.ts b/web/packages/common/src/utils/entityName.ts index 5c3f9b4260..5e9a589e5b 100644 --- a/web/packages/common/src/utils/entityName.ts +++ b/web/packages/common/src/utils/entityName.ts @@ -1,14 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { entitiesCreateEntityBodyNameRegExp } from '@nemo/sdk/generated/platform/zod/entity-store'; import { z } from 'zod'; -// Mirrors NAME_PATTERN in packages/nmp_common/src/nmp/common/entities/constants.py. -// The generated zod uses the looser pattern the service DTOs advertise, which lets -// invalid names through to a 422 from the entity store. -export const ENTITY_NAME_REGEXP = /^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? Date: Thu, 30 Jul 2026 12:39:03 -0700 Subject: [PATCH 12/12] refactor(studio): correct the stale name-override comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both claimed the generated zod uses a looser pattern than the entity store. That stopped being true once the create DTOs started declaring NAME_PATTERN — the generated schema now carries the identical rule. The override survives only because generated zod reports a generic pattern mismatch, while entityNameSchema names the rule the value breaks and suggests a repair. ExperimentCreateModal keeps its version of the comment: CreateExperimentBody.name is still a bare zod.string() with no pattern at all. Signed-off-by: mschwab --- .../studio/src/components/FilesetCreateModal/constants.ts | 3 +-- web/packages/studio/src/routes/FilesetNewRoute/types.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/web/packages/studio/src/components/FilesetCreateModal/constants.ts b/web/packages/studio/src/components/FilesetCreateModal/constants.ts index 59ab137075..9d61bc735c 100644 --- a/web/packages/studio/src/components/FilesetCreateModal/constants.ts +++ b/web/packages/studio/src/components/FilesetCreateModal/constants.ts @@ -13,8 +13,7 @@ export enum StorageMode { export type SupportedPurpose = typeof FilesetPurpose.dataset | typeof FilesetPurpose.model; -// Override the SDK-generated `name` validation, which uses a looser pattern than the -// entity store enforces. +// Same pattern as the generated zod, but reports which rule the value breaks. export const filesetCreateFormSchema = FilesCreateFilesetBody.pick({ name: true, description: true, diff --git a/web/packages/studio/src/routes/FilesetNewRoute/types.ts b/web/packages/studio/src/routes/FilesetNewRoute/types.ts index 0b12919dc9..fc6d9bd2ee 100644 --- a/web/packages/studio/src/routes/FilesetNewRoute/types.ts +++ b/web/packages/studio/src/routes/FilesetNewRoute/types.ts @@ -7,8 +7,7 @@ import { FilesCreateFilesetBody } from '@nemo/sdk/generated/platform/zod/files'; import { DATASET_TYPE_CUSTOM, DATASET_TYPE_SAMPLE } from '@studio/routes/FilesetNewRoute/constants'; import { z } from 'zod'; -// Override the SDK-generated name validation, which uses a looser pattern than the -// entity store enforces. +// Same pattern as the generated zod, but reports which rule the value breaks. export const DatasetCreateFilesetFormSchema = FilesCreateFilesetBody.extend({ name: z.string().trim().pipe(entityNameSchema('Name')), purpose: z.nativeEnum(FilesetPurpose),