Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
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
12 changes: 12 additions & 0 deletions docs/api/sagemaker_core.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ Utilities
:members:
:undoc-members:

.. automodule:: sagemaker.core.instance_types
:members:
:undoc-members:

.. automodule:: sagemaker.core.hub_content
:members:
:undoc-members:

.. automodule:: sagemaker.core.hub_catalog
:members:
:undoc-members:

Exceptions
----------

Expand Down
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
47 changes: 46 additions & 1 deletion docs/training/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,58 @@ 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 datetime import timedelta

from sagemaker.core.resources import HubContent

# Every model of the public hub, with the fields the hub tags it with.
# get_all describes 4 models at a time (max_workers=4).
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")
]

# The same list from the hub catalog cache, built like the JumpStart models cache: an
# LRU cache of hubs in memory (6 hour horizon) backed by one JSON file per hub. The first
# call describes every model, a call inside the horizon makes no request, and a call
# after it lists the hub and describes only the models whose version changed.
models = HubContent.get_all_cached("SageMakerPublicHub")
HubContent.set_cache_kwargs(cache_expiration_horizon=timedelta(days=1), max_workers=8)

# 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())

# Server-side search on the document fields, paged for you
from sagemaker.core.hub_content import HubContentFilter

apache_meta_models = HubContent.search(
"SageMakerPublicHub",
filters=[
HubContentFilter.metadata("license", "apache-2.0"),
HubContentFilter.metadata("provider", "Meta"),
],
)

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
- ``HubContent.search()`` filters a hub server side on ``Metadata.<Field>`` through ``HubContentFilter``, inside the 20-filter and 1024-character service limits
- ``HubContent.get_all_cached()`` serves a hub from ``HubCatalogCache``, the same ``LRUCache`` shape as ``JumpStartModelsCache`` (20 hubs, 6 hour horizon), backed by one JSON file per hub under ``SAGEMAKER_HUB_CACHE_DIR`` or the user cache directory. It describes only the contents whose ``HubContentVersion`` changed. ``refresh_cache()`` lists again and reports the counts, ``clear_cache()`` drops the memory and the file, and ``set_cache_kwargs()`` tunes the horizon, the item cap, the worker count, and the directory

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

Expand Down Expand Up @@ -434,7 +479,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
9 changes: 6 additions & 3 deletions migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,9 @@ pipeline = Pipeline(
| Fine-tune foundation models | `JumpStartEstimator(model_id=...).fit()` | V3: jumpstart-training-example.ipynb | 🔄 REPLACED |
| E2E training + inference | Separate steps | jumpstart-e2e-training-example.ipynb | 🔄 REPLACED |
| Marketplace algorithms | `sagemaker.algorithm.AlgorithmEstimator` | `sagemaker.core.resources.Algorithm` (13 classes) | 🔄 REPLACED |
| List hub models | `list_jumpstart_models()` (manifest) | `HubContent.get_all(hub_name, hub_content_type)` pages `ListHubContents` and describes 4 models at a time. `HubContent.get_all_cached(hub_name)` serves the hub from an `LRUCache` with the JumpStart cache defaults (20 hubs, 6 hour horizon), backed by a JSON file per hub, and describes only the models whose version changed | 🆕 NEW IN V3 |
| Read model metadata | `JumpStartModelSpecs` fields | `HubContent.license`, `.provider`, `.model_size`, `.tasks`, `.capabilities`, `.recipes`, `.supports_technique("sft")` read from the hub keywords and document | 🆕 NEW IN V3 |
| Search hub models server side | None (`search_public_hub_models` filters client side) | `HubContent.search(hub_name, filters=[HubContentFilter.metadata("license", "Apache-2.0")])` runs `Search(Resource="HubContent")` | 🆕 NEW IN V3 |

## Configuration Management

Expand Down Expand Up @@ -559,16 +562,16 @@ compute = Compute(
instance_type="ml.m5.xlarge",
instance_count=1,
volume_size_in_gb=30,
use_spot_instances=True,
max_wait_time_in_seconds=7200
enable_managed_spot_training=True
)
networking = Networking(
subnets=["subnet-12345"],
security_group_ids=["sg-12345"],
enable_inter_container_traffic_encryption=True
)
stopping_condition = StoppingCondition(
max_runtime_in_seconds=3600
max_runtime_in_seconds=3600,
max_wait_time_in_seconds=7200
)
model_trainer = ModelTrainer(
training_image="...",
Expand Down
109 changes: 84 additions & 25 deletions sagemaker-core/src/sagemaker/core/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1688,12 +1688,68 @@ def create_paginator_config(max_items: int = None, page_size: int = None) -> Dic
}


def format_tags(tags: Tags) -> List[TagsDict]:
"""Process tags to turn them into the expected format for Sagemaker."""
if isinstance(tags, dict):
return [{"Key": str(k), "Value": str(v)} for k, v in tags.items()]
def _tag_text(value: Any) -> Union[str, PipelineVariable]:
"""Return a tag key or value as the service string, keeping a pipeline variable intact."""
if value is None or is_pipeline_variable(value):
return value
return str(value)


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: In _tag_dict, a value of None is passed through unchanged by _tag_text, so a tag dict missing Value (e.g. {"Key": "x"}) now normalizes to {"Key": "x", "Value": None}. The previous {key: value} path stringified everything (str(v)), so this is a behavior change that can emit "Value": None into a request. Worth confirming the service/serializer tolerates a null Value, or defaulting the missing value to "".

def _tag_dict(key: Any, value: Any) -> TagsDict:
return {"Key": _tag_text(key), "Value": _tag_text(value)}


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 of strings.

Accepts the boto form, the ``{"key": ..., "value": ...}`` form that the
``sagemaker.core.resources`` classes take, a ``(key, value)`` pair, and a ``Tag``
shape object (anything with ``key`` and ``value`` attributes). The key and the value
come out as ``str`` whatever the input representation, except a ``PipelineVariable``,
which the request serializer resolves later. 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 _tag_dict(tag["Key"], tag.get("Value"))
if "key" in tag:
return _tag_dict(tag["key"], 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 isinstance(tag, (tuple, list)) and len(tag) == 2:
return _tag_dict(tag[0], tag[1])
if hasattr(tag, "key") and hasattr(tag, "value"):
return _tag_dict(tag.key, tag.value)
raise TypeError(
"A tag must be a {'Key': ..., 'Value': ...} dict, a {'key': ..., 'value': ...} dict, "
f"a (key, value) pair, or a Tag object, got {type(tag).__name__}."
)


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

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

* 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, ``(key, value)`` pairs, ``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 [_tag_dict(key, value) for key, value in tags.items()]
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,38 +2180,43 @@ def tag_exists(tag: TagsDict, curr_tags: Optional[Tags]) -> bool:
if curr_tags is None:
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


def _validate_new_tags(new_tags: Optional[Tags], curr_tags: Optional[Tags]) -> Optional[Tags]:
"""Validates new tags against existing tags.
def _validate_new_tags(
new_tags: Optional[Tags], curr_tags: Optional[Tags]
) -> Optional[List[TagsDict]]:
"""Merge new tags into the current tags, without a duplicate key.

The two inputs take any representation ``format_tags`` accepts. The result is the
merged list in boto form, so a caller that stores it (``ModelBuilder.add_tags``)
holds one shape whatever shape it received. ``None`` comes back only when the two
inputs are ``None``.

Args:
new_tags (Optional[Tags]): The new tags.
curr_tags (Optional[Tags]): The current tags.

Returns:
Optional[Tags]: The updated tags.
Optional[List[TagsDict]]: The merged tags in boto form.
"""
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 +2231,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
2 changes: 2 additions & 0 deletions sagemaker-core/src/sagemaker/core/config_schema.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""JSON schema of the SageMaker Python SDK configuration file."""

SAGEMAKER_PYTHON_SDK_CONFIG_SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
Expand Down
Loading
Loading