Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
450c866
feat: expose the created job as latest_training_job and return it fro…
evakravi Oct 23, 2026
6e9df41
fix: make ModelBuilder.deploy(tags=...) a named parameter that reache…
evakravi Oct 23, 2026
c2db676
feat(core): add HubContent.get_all and keep OriginalCreationTime on l…
evakravi Oct 23, 2026
d352fe5
feat(core): let HubContent read its own metadata from search keywords…
evakravi Oct 23, 2026
8c8dd9a
feat(train): call on_submit with the created TrainingJob before the wait
evakravi Oct 23, 2026
b04f4c9
feat(serve): name the ModelBuilder.deploy endpoint options and reject…
evakravi Oct 23, 2026
b6959d8
docs: fix the managed spot example in the migration guide
evakravi Oct 23, 2026
93c0a67
feat(core): add HubContent.search with Metadata filters on the Search…
evakravi Oct 23, 2026
097192a
fix(core): accept (key, value) pairs in format_tags again
evakravi Oct 23, 2026
fb7c616
feat(train): add get_metric_history for MLflow metric data
evakravi Oct 23, 2026
a4c2786
feat(train): type the reward-function contract and use one Lambda run…
evakravi Oct 23, 2026
5a134b9
feat(train): validate LLM-as-Judge custom metrics before the job starts
evakravi Oct 23, 2026
2c09cf5
feat(core): add S3Downloader byte-range reads, line streaming, and he…
evakravi Oct 23, 2026
cd59491
feat(train): detect a dataset format from a local or S3 sample
evakravi Oct 23, 2026
41aae12
feat(serve): add Bedrock model listing and an invocable deployment ha…
evakravi Oct 23, 2026
d906911
feat(core): describe SageMaker instance types through one public helper
evakravi Oct 23, 2026
6e820f6
feat(core): add ModelPackageGroup.get_or_create
evakravi Oct 23, 2026
0727c47
fix(core): return a session-bound ProcessingJob with its ARN from Pro…
evakravi Oct 23, 2026
c6294e1
feat(core): add raise_on_stopped to every generated wait() method
evakravi Oct 23, 2026
4c9bedb
feat(core): add Endpoint.diagnostics and Endpoint.recent_logs
evakravi Oct 23, 2026
4b89520
feat(train): add FineTuningSweep for serverless trainer parameter sweeps
evakravi Oct 23, 2026
a20a1e6
fix: address review comments on get_all docstrings, format_tags typin…
evakravi Oct 23, 2026
326e8bd
chore(merge): merge origin/master into feat/hub-content-trainer-builder
evakravi Oct 24, 2026
76c02cd
fix(codegen): make the generated modules pass black-check and pydocstyle
evakravi Oct 24, 2026
fa28a67
fix: scope deploy() tags to the call and stringify every tag represen…
evakravi Oct 24, 2026
7ffd898
feat(core): describe listed hub contents concurrently through Resourc…
evakravi Oct 26, 2026
cb1a6ea
feat(core): add HubContent.get_all_cached with a local cache of descr…
evakravi Oct 26, 2026
8f9ea7f
feat(core): emit telemetry from HubContent.search, get_all_cached, an…
evakravi Oct 26, 2026
f529db2
refactor(core): build the hub catalog cache on LRUCache, the JumpStar…
evakravi Oct 26, 2026
625a4f0
feat(core): serve HubContent.get_all from the hub catalog cache on th…
evakravi Oct 26, 2026
a081545
fix: address the review findings on search telemetry, filter casing, …
evakravi Oct 26, 2026
4042f61
fix: return the HyperPod TrainingJob from train() and address the tra…
evakravi Oct 26, 2026
5ea7b09
chore(merge): merge origin/master into feat/hub-content-trainer-builder
evakravi Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/model_customization/model_customization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,15 @@ Key Features

# Re-attach (HyperPod — needs cluster name for log group resolution)
from sagemaker.train import SFTTrainer
from sagemaker.core.resources import TrainingJob
from sagemaker.core.training.configs import HyperPodCompute

trainer = SFTTrainer(
model="nova-textgeneration-micro",
training_dataset="s3://unused",
compute=HyperPodCompute(cluster_name="my-cluster", instance_type="ml.p5.48xlarge")
)
trainer._latest_training_job = "my-hp-job"
trainer.latest_training_job = TrainingJob.get("my-hp-job")
df = trainer.show_metrics()

**Monitoring: stream_logs()**
Expand Down
24 changes: 23 additions & 1 deletion docs/training/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,35 @@ Train pre-configured models from the SageMaker JumpStart hub using ``ModelTraine
# Complex queries with filters
text_gen = search_public_hub_models(query="@task:text-generation")

**Reading a Hub Model's Metadata:**

.. code-block:: python

from sagemaker.core.resources import HubContent

# Every model of the public hub, with the fields the hub tags it with
small_sft_models = [
content.hub_content_name
for content in HubContent.get_all(hub_name="SageMakerPublicHub", hub_content_type="Model")
if content.supports_technique("sft") and content.model_size in ("<1b", "1b-10b")
]

# One model; the document fills in fields the 50-keyword cap dropped
content = HubContent.get(
hub_name="SageMakerPublicHub",
hub_content_type="Model",
hub_content_name="meta-textgeneration-llama-3-2-1b",
)
print(content.license, content.provider, content.tasks, content.fine_tuning_techniques())

Key points:

- ``from_jumpstart_config()`` auto-configures training image, instance type, and default hyperparameters
- Override any default hyperparameters while keeping proven defaults for the rest
- JumpStart provides built-in datasets so you can start training immediately
- Supports HuggingFace, XGBoost, CatBoost, LightGBM, and many more frameworks
- Use ``list_jumpstart_models()`` and ``search_public_hub_models()`` to discover available models
- ``HubContent`` exposes ``tasks``, ``license``, ``provider``, ``model_size``, ``context_window``, ``languages``, ``capabilities``, and ``recipes`` read from its search keywords and document, so no caller parses ``HubContentSearchKeywords`` by hand

:doc:`Full example notebook <../v3-examples/training-examples/jumpstart-training-example>`

Expand Down Expand Up @@ -434,7 +456,7 @@ Provide an ordered list of candidate instance types and the platform launches th

.. code-block:: python

training_job = model_trainer._latest_training_job
training_job = model_trainer.latest_training_job
training_job.refresh()

resource_config = training_job.resource_config
Expand Down
77 changes: 57 additions & 20 deletions sagemaker-core/src/sagemaker/core/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1688,12 +1688,53 @@ def create_paginator_config(max_items: int = None, page_size: int = None) -> Dic
}


def _normalize_tag(tag: Any) -> TagsDict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Low: format_tags is annotated -> List[TagsDict] but now returns None unchanged when tags is None. Consider Optional[List[TagsDict]] to match the actual contract (several callers like _deploy_recommendation rely on the None passthrough).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Changed to Optional[List[TagsDict]], with Optional[Tags] on the parameter. Commit a20a1e6.

"""Return one tag as a boto-style ``{"Key": ..., "Value": ...}`` dict.

Accepts the boto form, the ``{"key": ..., "value": ...}`` form that the
``sagemaker.core.resources`` classes take, and a ``Tag`` shape object (anything
with ``key`` and ``value`` attributes). Any other input raises ``TypeError`` so a
malformed tag fails at the call site instead of as a ``KeyError`` deep in a
deploy path.
"""
if isinstance(tag, dict):
if "Key" in tag:
return {"Key": tag["Key"], "Value": tag.get("Value")}
if "key" in tag:
return {"Key": tag["key"], "Value": tag.get("value")}
raise TypeError(
"A tag dict must have a 'Key' (or 'key') entry, "
f"got {sorted(tag)}. Pass a {{key: value}} mapping to set many tags at once."
)
if hasattr(tag, "key") and hasattr(tag, "value"):
return {"Key": tag.key, "Value": tag.value}
raise TypeError(
"A tag must be a {'Key': ..., 'Value': ...} dict, a {'key': ..., 'value': ...} dict, "
f"or a Tag object, got {type(tag).__name__}."
)


def format_tags(tags: Tags) -> List[TagsDict]:
"""Process tags to turn them into the expected format for Sagemaker."""
"""Process tags to turn them into the expected format for Sagemaker.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Low: format_tags is now stricter: inputs the old implementation returned unchanged (e.g. a list containing a non-dict/non-pair element, or a scalar) now raise TypeError via _normalize_tag. This is the intended behavior, but because format_tags/tag_exists/remove_tag_with_key are shared helpers invoked on stored curr_tags, any existing caller that previously stashed tags in an unexpected shape will now raise instead of silently passing through. Worth confirming all internal callers (model_builder, model_builder_utils) only ever hold the accepted representations.

Every accepted representation comes out as a list of boto-style
``{"Key": ..., "Value": ...}`` dicts:

* a ``{key: value}`` mapping, one tag per entry;
* a single tag dict or ``Tag`` object;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Low: format_tags is now the single normalizer for every tag path (tag_exists, _validate_new_tags, remove_tag_with_key, and the serve layer), but it stringifies values only for the {key: value} mapping branch (str(k), str(v)), while _normalize_tag preserves the raw value for lists, (key, value) pairs, boto/core dicts, and Tag objects. The result is that format_tags({"n": 1}) yields {"Value": "1"} but format_tags([("n", 1)]) yields {"Value": 1}. Consider normalizing to a string consistently (or not at all) so the output type does not depend on the input representation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Every representation now comes out with str keys and values (format_tags({"n": 1}), format_tags([("n", 1)]), the core dict form, and Tag objects all give {"Key": "n", "Value": "1"}). A PipelineVariable stays intact for the request serializer. Commit fa28a67.

* a list of tag dicts in either casing, ``Tag`` objects, or a mix.

``None`` is returned unchanged.
"""
if tags is None:
return tags
if isinstance(tags, dict):
if "Key" in tags or "key" in tags:
return [_normalize_tag(tags)]
return [{"Key": str(k), "Value": str(v)} for k, v in tags.items()]

return tags
if isinstance(tags, (list, tuple)):
return [_normalize_tag(tag) for tag in tags]
return [_normalize_tag(tags)]


def _get_resolved_path(path):
Expand Down Expand Up @@ -2124,8 +2165,9 @@ def tag_exists(tag: TagsDict, curr_tags: Optional[Tags]) -> bool:
if curr_tags is None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Low: _validate_new_tags now calls format_tags(curr_tags) once and then tag_exists(new_tag, merged) inside the loop, and tag_exists itself calls format_tags(curr_tags) on the already-boto-form merged for every new tag. For N current and M new tags this re-normalizes the full list M times (O(N*M)). Harmless for typical tag counts, but tag_exists could take the already-normalized key list to avoid the repeated format_tags pass.

return False

for curr_tag in curr_tags:
if tag["Key"] == curr_tag["Key"]:
key = _normalize_tag(tag)["Key"]
for curr_tag in format_tags(curr_tags):
if key == curr_tag["Key"]:
return True

return False
Expand All @@ -2141,21 +2183,18 @@ def _validate_new_tags(new_tags: Optional[Tags], curr_tags: Optional[Tags]) -> O
Returns:
Optional[Tags]: The updated tags.
"""
if curr_tags is None:
return new_tags
if new_tags is None:
return format_tags(curr_tags) if curr_tags is not None else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Low: _validate_new_tags previously returned new_tags / curr_tags largely unchanged (a dict stayed a dict). It now always returns a normalized List[TagsDict] (or None). This is a reasonable cleanup, but it is a behavioral change for any caller that relied on getting back a single-dict form; worth confirming no downstream consumer in the SDK depends on the old passthrough shape.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed: ModelBuilder.add_tags is the one caller in the SDK, and it stores the result, so one shape there is the point. The docstring now states the contract (merged list in boto form, None only when the two inputs are None). The three existing test_common_utils.py cases still pass. Commit fa28a67.


if curr_tags and isinstance(curr_tags, dict):
curr_tags = [curr_tags]
if curr_tags is None:
return format_tags(new_tags)

if isinstance(new_tags, dict):
if not tag_exists(new_tags, curr_tags):
curr_tags.append(new_tags)
elif isinstance(new_tags, list):
for new_tag in new_tags:
if not tag_exists(new_tag, curr_tags):
curr_tags.append(new_tag)
merged = format_tags(curr_tags)
for new_tag in format_tags(new_tags):
if not tag_exists(new_tag, merged):
merged.append(new_tag)

return curr_tags
return merged


def remove_tag_with_key(key: str, tags: Optional[Tags]) -> Optional[Tags]:
Expand All @@ -2170,11 +2209,9 @@ def remove_tag_with_key(key: str, tags: Optional[Tags]) -> Optional[Tags]:
"""
if tags is None:
return tags
if isinstance(tags, dict):
tags = [tags]

updated_tags = []
for tag in tags:
for tag in format_tags(tags):
if tag["Key"] != key:
updated_tags.append(tag)

Expand Down
Loading
Loading