Skip to content

feat: hub search and metadata, trainer job handles, typed deploy options, endpoint diagnostics, Bedrock and S3 helpers - #6533

Closed
evakravi wants to merge 33 commits into
aws:masterfrom
evakravi:feat/hub-content-trainer-builder
Closed

evakravi wants to merge 33 commits into
aws:masterfrom
evakravi:feat/hub-content-trainer-builder

Conversation

@evakravi

@evakravi evakravi commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Eighteen changes to the surfaces an agent uses to pick a hub model, train it, evaluate it, and deploy it. Each closes a gap where the SageMaker agent skills (aws-ai-ml) drop to raw boto3 or a private attribute because the SDK has no public handle. One commit per change, each with a regression test that fails on master and passes here. Nothing regenerates sagemaker-core from a newer botocore. In each code block, a # master: comment states what origin/master (4042a24f) offers, and a # This PR: comment states what the lines below it add. Every "Live run" line is from this branch in us-west-2 on 2026-10-25 and 2026-10-26. The hub runs target SageMakerPublicHub.

Hub Service: list, read, and search models

Before this PR, the SDK had no HubContent.get_all, no typed reader for HubContentSearchKeywords, and no binding for Search(Resource="HubContent"). The agent skills paged list_hub_contents by hand in three scripts, parsed @license: and @model-size: prefixes by hand, and built the Search expression by hand.

List the catalog (c2db676d, cb1a6ea6, f529db22, 625a4f05)

from datetime import timedelta

from sagemaker.core.resources import HubContent

# master: HubContent has no get_all(). Callers page boto3 list_hub_contents by hand.
# This PR: a classmethod with every ListHubContents filter, served from the hub catalog cache.
# The cache is built like JumpStartModelsCache: an LRUCache of hubs (20 items, 6 hour horizon)
# backed by one JSON file per hub. The first call lists the hub and describes every model on a
# thread pool of the host default size. A call inside the horizon makes no request. A call
# after it, or in a new process, lists the hub and describes only the models whose
# HubContentVersion changed.
models = HubContent.get_all(hub_name="SageMakerPublicHub", hub_content_type="Model")

# This PR: the list filters apply to the cached catalog, so a filtered call makes no request.
qwen3 = HubContent.get_all(hub_name="SageMakerPublicHub", hub_content_type="Model", name_contains="qwen3", sort_by="CreationTime", sort_order="Descending")

# This PR: original_creation_time, the catalog date that only the list summary carries.
newest = sorted(models, key=lambda m: m.original_creation_time, reverse=True)[:3]

# This PR: cache controls. refresh_cache lists again and reports the counts, clear_cache drops
# the memory and the file, set_cache_kwargs tunes the horizon, item cap, workers, and directory.
refresh = HubContent.refresh_cache("SageMakerPublicHub")
refresh.listed, refresh.reused, refresh.described, refresh.removed, refresh.cache_path
HubContent.set_cache_kwargs(cache_expiration_horizon=timedelta(days=1))
HubContent.clear_cache("SageMakerPublicHub")

# master: get_all_versions() exists but fails on refresh() with a missing HubName.
# This PR: every listed version carries the hub_name of the parent.
versions = list(qwen3[0].get_all_versions())

Live run: 766 bound HubContent resources in 60.3 s cold (8 list calls, 766 describes on the default pool of 32 workers, 44 throttle retries, 0 failures), against 95.7 s for one describe at a time. The 32.9 MB cache file lives in SAGEMAKER_HUB_CACHE_DIR or ~/.cache/sagemaker/hub-content. Inside the horizon the call takes 0.4 s with no request, a new process 1.9 s from the file, and refresh_cache 1.4 s with listed=766 reused=766 described=0 removed=0. name_contains="qwen3" returns 36 in 0.2 s with no request. get_all_versions() returns 4 versions of huggingface-vlm-qwen3-8-27b, where master fails with Missing required parameter in input: "HubName".

Read the metadata of a model (d352fe5d)

# master: HubContent.get() exists. The caller then parses hub_content_search_keywords
# ("@license:apache-2.0", "@model-size:1b-10b", ...) and the hub_content_document JSON by hand.
content = HubContent.get(hub_name="SageMakerPublicHub", hub_content_type="Model", hub_content_name="huggingface-llm-qwen2-5-7b-instruct")

# This PR: typed accessors on HubContent read the keywords and the document.
content.license, content.provider, content.model_size, content.context_window
content.tasks, content.languages, content.capabilities, content.recipes
content.customization_supported, content.bedrock_supported
content.fine_tuning_techniques(), content.supports_technique("dpo")

# This PR: the accessors work on the listed resources too, so a client-side filter is one line.
small_sft = [m.hub_content_name for m in models if m.supports_technique("sft") and m.model_size in ("<1b", "1b-10b")]

Live run: license, provider, model_size, and context_window are apache-2.0, qwen, 1b-10b, and 8k-32k. capabilities is ['bedrock_console', 'customization'], fine_tuning_techniques() is ['dpo', 'rlaif', 'rlvr', 'sft'], and small_sft holds 12 names. The document fills what the 50-keyword cap dropped. 79 models are at the cap, and the document alone supplies 30 licenses, 13 model sizes, and extra languages for 20. The mixin adds no model field: model_dump() is unchanged.

Search server side (93c0a67c, a0815453)

# master: the only search is sagemaker.core.jumpstart.search.search_public_hub_models(query),
# a client-side keyword match that lists the whole hub first and cannot see the document
# fields. Nothing in the SDK calls Search(Resource="HubContent").
# This PR: HubContent.search() and HubContentFilter, new in sagemaker.core.hub_content. search()
# returns the matching HubContent list after the last page.
from sagemaker.core.hub_content import HubContentFilter

# HubContentFilter.metadata maps a HubContent attribute to the indexed Metadata.<Field>. The
# value matches the document spelling, case-sensitive (Apache-2.0, not apache-2.0).
fine_tunable_small = HubContent.search("SageMakerPublicHub", filters=[
    HubContentFilter.metadata("tasks", "Text Generation"),
    HubContentFilter.metadata("model_size", ["<1B", "1B-10B"]),
    HubContentFilter.metadata("capabilities", "CUSTOMIZATION"),
])
apache_on_bedrock = HubContent.search("SageMakerPublicHub", filters=[
    HubContentFilter.metadata("license", ["Apache-2.0", "Apache 2.0"]),
    HubContentFilter.metadata("capabilities", "BEDROCK_CONSOLE"),
])

# A raw filter and a keyword filter for the fields the service indexes directly.
llama3 = HubContent.search("SageMakerPublicHub", filters=[
    HubContentFilter("HubContentName", "Contains", "llama-3"),
    HubContentFilter.keyword("@capability:customization"),
])

# Each result is a full HubContent, so the accessors from the previous section work.
for m in fine_tunable_small:
    print(m.hub_content_name, m.license, m.model_size, m.fine_tuning_techniques())

Live run: fine_tunable_small returns 9 in 0.8 s, apache_on_bedrock 45 in 1.0 s, llama3 4 in 0.3 s, and an unfiltered search all 766 in 8.5 s. A cold get_all takes 60.3 s.

HubContentFilter.metadata maps the attribute to the indexed field (license to Metadata.License) and rejects an unknown attribute. The 20-filter and 1024-character limits raise ValueError from the search() call, before a request. Metadata.<Field> matches the document spelling: Apache-2.0 matches 112 models, apache-2.0 0, meta 74, Meta 0. The docstrings and docs use the document spelling. search() returns a list, not a generator, so the @_telemetry_emitter decorator times the paged calls and records a ValueError as a failure (a0815453). Search records carry no OriginalCreationTime, so get_all stays the call for catalog dates.

Training jobs

# master exports SFTTrainer. This PR adds FineTuningSweep, SweepConfig, and get_metric_history.
from sagemaker.train import SFTTrainer, FineTuningSweep, SweepConfig, get_metric_history

# master: ModelTrainer.train() returns None and keeps the job in the private _latest_training_job.
# The HyperPod path returns the job name string.
# This PR: train() returns the TrainingJob on every path, latest_training_job is a public
# property on every trainer, and on_submit runs as soon as the service accepts the job.
job = trainer.train(wait=False, on_submit=lambda job: print(job.training_job_arn))
assert job is trainer.latest_training_job

# master: wait(poll, timeout, logs) treats Stopped as a terminal success and returns.
# This PR: raise_on_stopped raises FailedStatusError on a Stopped job.
job.wait(raise_on_stopped=True)

# master: plot_training_metrics() draws the MLflow metrics. No function returns them as data.
# This PR: get_metric_history() returns MetricPoint lists, with "loss" resolved per recipe.
history = get_metric_history(job, metrics=["loss", "val_loss"])
history["loss"][-1].value

# This PR: FineTuningSweep runs a grid, random, or ASHA sweep over the serverless trainers.
def make_trainer(hp):
    return SFTTrainer(model="huggingface-llm-qwen2-5-7b-instruct", model_package_group="my-group", training_dataset="s3://bucket/train.jsonl", overrides={"training_config": hp})

def objective(job):
    return get_metric_history(job, metrics=["val_loss"])["val_loss"][-1].value

config = SweepConfig({"learning_rate": [1e-5, 5e-5], "num_epochs": [1, 2]}, max_trials=4, max_parallel=2, manifest_s3_uri="s3://bucket/sweep.json")
sweep = FineTuningSweep(make_trainer, objective, config)
trials = sweep.run()
sweep.best_trial
resumed = FineTuningSweep.resume("s3://bucket/sweep.json", make_trainer, objective)

ModelTrainer.train() returns the created TrainingJob and latest_training_job is public on every trainer (450c866d). The setter takes a TrainingJob or None and raises TypeError for a job name string, which migration.md names. on_submit runs as soon as the service accepts the job, before any wait, on the serverless, serverful, and HyperPod paths (8c8dd9ad).

The HyperPod path returns the same TrainingJob it hands to on_submit, not the job name string. The return type of train() no longer depends on the compute target (4042f612). A callback that raises leaves the job on latest_training_job, propagates, and skips the wait. get_metric_history returns MLflow points as data, with loss resolving to total_loss or train/loss by recipe (fb7c6167). FineTuningSweep runs grid, random, or ASHA sweeps over the serverless trainers with a resumable S3 manifest, which AMT cannot do (HyperParameterTrainingJobDefinition has no serverless_job_config) (4b89520e).

Evaluation

# master: verify_reward_function lives in sagemaker.train.common_utils.rlvr_reward_verifier and
# takes (reward_function, sample_data, validate_format, compute, is_nova).
# This PR: exported from sagemaker.train, with typed samples and an envelope choice.
from sagemaker.train import verify_reward_function
from sagemaker.train.evaluate import LLMAsJudgeEvaluator
from sagemaker.ai_registry import detect_dataset_format

verify_reward_function("reward.py", samples, envelope="eval_oss")

# master: a private _validate_custom_metrics_json only parses the JSON string. A metric that
# breaks the format rules fails inside the job.
# This PR: a public validate_custom_metrics() that returns every violation before the job.
LLMAsJudgeEvaluator.validate_custom_metrics(metrics)

# master: DatasetFormatDetector.validate_dataset(file_path) returns True or False for a local file.
# This PR: detect_dataset_format() reads a local file or one S3 byte range and names the format.
detect_dataset_format("s3://bucket/train.jsonl")

verify_reward_function is exported with typed RewardSample and EvalRewardSample and an envelope of oss, nova, eval_oss, or eval_nova. validate_format, compute, and is_nova stay the third to fifth positional parameters, so a first-release call still works (a0815453). Evaluator.create and CustomAgentLambda share one Lambda runtime, python3.12 (a4c27861). validate_custom_metrics reports every violation before the job starts, for example ['Metric [0]: instructions must contain {{prompt}} or {{prediction}}.'] (5a134b93). detect_dataset_format reads a local file or one S3 byte range. It names the format from the 7 SDK schemas plus the Nova names of the skill. Four committed fixtures return hf_prompt_completion, dpo, verl, and genqa at confidence 1.0 (cd594911).

Deployment

from sagemaker.core.enums import EndpointType
from sagemaker.core.model_monitor.data_capture_config import DataCaptureConfig
# This PR: a new module.
from sagemaker.serve.bedrock_deployment import list_invocable_foundation_models

# master: deploy() reads data_capture_config, endpoint_type, and inference_component_name from
# **kwargs on the core endpoint path only, never reads fallback_instance_types or tracing_config,
# and a misspelled name deploys an endpoint without the option.
# This PR: six named parameters validated once and applied on every path, TypeError for an unknown name.
endpoint = builder.deploy(
    endpoint_name="demo",
    instance_type="ml.g6.xlarge",
    fallback_instance_types=["ml.g5.xlarge"],
    tags={"team": "ml"},
    data_capture_config=DataCaptureConfig(enable_capture=True),
    endpoint_type=EndpointType.MODEL_BASED,
)
builder.deploy(endpoint_name="demo", fallback_instance_type=["ml.g5.xlarge"])  # TypeError: unexpected keyword argument 'fallback_instance_type'

# master: Endpoint has refresh(), invoke(), update(), delete(), and the waiters. No diagnostics.
# This PR: diagnostics() and recent_logs() on the generated Endpoint.
diag = endpoint.diagnostics(window_minutes=5)
diag.endpoint_status, diag.failure_reason, diag.variants[0].instance_type, diag.metrics["ModelLatency/p99"].values, diag.errors
endpoint.recent_logs(window_minutes=15, limit=100)

# master: BedrockModelBuilder.deploy() returns the raw response dict. There is no model listing
# and no invoke handle.
# This PR: list_invocable_foundation_models(), and a BedrockDeployment recorded by deploy().
models = list_invocable_foundation_models(region="us-west-2")
handle = bedrock_builder.latest_deployment()
handle.wait_until_ready()
handle.converse([{"role": "user", "content": [{"text": "Hi"}]}])

ModelBuilder.deploy names tags, data_capture_config, tracing_config, fallback_instance_types, endpoint_type, and inference_component_name, and applies each on every deploy path or raises. An unknown kwarg raises TypeError instead of deploying without it. fallback_instance_types becomes InstancePools with the primary type at priority 1 (6e9df413, b04f4c93, 097192a5). format_tags turns a missing tag value into "", the empty string the service model accepts, not None (a0815453). Endpoint.diagnostics() returns the status, failure reason, per-variant instance type from the endpoint config, 11 CloudWatch metrics, and recent logs. Live run on a Failed endpoint: failure_reason starts Unable to provision requested ML compute capacity, metrics has the 11 keys, and the missing log group lands in errors as FilterLogEvents failed: An error occurred (ResourceNotFoundException) instead of raising (4c9bedb0). list_invocable_foundation_models() joins ListFoundationModels with inference profiles: 89 of 92 TEXT models in us-west-2, 43 through a cross-region profile. BedrockDeployment waits through ModelNotReadyException, then invoke and converse. BedrockModelBuilder.deploy() records it as latest_deployment() (41aae122).

Core resources

from sagemaker.core.s3 import S3Downloader
from sagemaker.core.resources import ModelPackageGroup
# This PR: new modules.
from sagemaker.core.model_package_group import ModelPackageGroupSpec
from sagemaker.core.instance_types import describe_instance_type, list_instance_types

# master: S3Downloader.read_bytes(s3_uri) downloads the whole object. read_file, download, list.
# This PR: byte_range on read_bytes, plus iter_lines() and head().
S3Downloader.read_bytes("s3://bucket/train.jsonl", byte_range=(0, 1023))
S3Downloader.head("s3://bucket/train.jsonl").content_length
for line in S3Downloader.iter_lines("s3://bucket/train.jsonl"):
    break

# master: ModelPackageGroup.get() and create(). The caller catches the not-found error itself.
# This PR: get_or_create() with a create-only spec.
group = ModelPackageGroup.get_or_create("my-group", spec=ModelPackageGroupSpec(model_package_group_description="fine-tuned Qwen"))

# master: instance_types has retrieve() and retrieve_default() for a JumpStart model only.
# This PR: the hosting instance type names, and the hardware of one type through EC2.
list_instance_types()
describe_instance_type("ml.g5.xlarge")

S3Downloader.read_bytes(byte_range=), iter_lines, and head (2c09cf5f). ModelPackageGroup.get_or_create (6e820f61). Processor._start_new returns a session-bound ProcessingJob with its ARN, so latest_job.refresh() no longer hits the global default region (#5796) (0727c476). Every generated wait() takes raise_on_stopped (c6294e14). list_instance_types() returns the ProductionVariantInstanceType enum of the loaded service model (277 names in botocore 1.43.118) with no request. describe_instance_type("ml.g5.xlarge"), with an optional region as list_instance_types, returns InstanceSpec(name='ml.g5.xlarge', vcpus=4, memory_mib=16384, gpu_count=1, gpu_memory_mib=22888, gpu_name='A10G'). The two replace two private EC2 lookups in model_builder.py (d9069112). migration.md names Compute(enable_managed_spot_training=True) and StoppingCondition(max_wait_time_in_seconds=) in place of the Compute(use_spot_instances=...) example that fails with extra_forbidden (b6959d83).

HubCatalogCache (hub_catalog.py) follows JumpStartModelsCache. It is the same sagemaker.core.utilities.cache.LRUCache with the same defaults (20 items, 6 hour horizon). The LRUCache calls a retrieval function on a miss or an expired entry. A static HubCatalogAccessor holds the process cache and takes set_cache_kwargs. The retrieval function describes only the items whose version changed, as the JumpStart one reads the manifest only when its ETag changed. The items persist to a JSON file, so a new process starts from the previous catalog. The LRUCache reads and writes run under one lock. A retrieval runs under a per-key lock, so a slow hub blocks no hit on another hub.

get reads the old value before LRUCache.get pops an expired element, so an expired entry refreshes from memory without a file read (a0815453). The describes complete through as_completed, so one failure drops no result of another worker: every described item is written to the file, then the first failure is raised (4042f612). LRUCache.put on a present key raised AttributeError (curr_value.element). The fix reads curr_value.value (f529db22).

The describes run on a ThreadPoolExecutor of the host default size (min(32, cpu_count + 4)). The client retries in the botocore adaptive mode with a 32-connection pool (catalog_client_config), passed through a new config argument of Base.get_sagemaker_client. On the public hub the default pool failed 462 of 766 describes in the standard mode and 0 in the adaptive mode (625a4f05).

The generator emits no get_all for HubContent (HAND_WRITTEN_GET_ALL_RESOURCES). HubContentMixin provides it with the ListHubContents parameters of a generated get_all, and HubCatalogFilters applies them to the cached catalog. Hand-written behavior for a generated class lives in its own module (hub_content.py, hub_catalog.py, endpoint.py, model_package_group.py). A RESOURCE_MIXINS table in the generator mixes it in, so resources.py emits class HubContent(HubContentMixin, Base) and the two others. HubContent.search, get_all, and refresh_cache carry the @_telemetry_emitter decorator that CONTRIBUTING.md asks of public V3 entry points (8f9ea7fb).

The decorator wraps a boto3.Session passed as session into the telemetry Session. A classmethod call reports on the account and region of the caller and builds no default session (4042f612). python src/sagemaker/core/tools/codegen.py regenerates resources.py to the committed content.

Tests

Twenty-two new test files, each failing on origin/master at collection or with the old behavior: sagemaker-core/tests/unit/test_hub_content_get_all.py, test_hub_content_mixin.py, test_hub_content_search.py, test_hub_catalog_cache.py, test_lru_cache.py, test_endpoint_diagnostics.py, test_model_package_group_get_or_create.py, test_processing_start_new_arn.py, test_wait_raise_on_stopped.py, test_instance_types.py, s3/test_client_read_primitives.py; sagemaker-train/tests/unit/train/test_latest_training_job.py, test_fine_tuning_sweep.py, common_utils/test_get_metric_history.py, common_utils/test_reward_function_contract.py, evaluate/test_validate_custom_metrics.py, ai_registry/test_evaluator_runtime.py, ai_registry/test_detect_dataset_format.py; sagemaker-serve/tests/unit/test_model_builder_deploy_tags.py, test_model_builder_deploy_options.py, test_bedrock_deployment.py, test_model_builder_instance_types.py.

Full unit suites on this branch after the merge of master (4042a24f), pytest -n 8: sagemaker-core 4109 passed, 24 skipped, 5 failed and sagemaker-train 3648 passed, 19 skipped, 2 failed (at 4042f612), sagemaker-serve 2227 passed, 25 skipped, 59 failed. Every failure fails identically on origin/master on the same host. The 5 test_common_utils.py path tests fail with DID NOT RAISE ValueError. sm_recipes/test_utils.py::test_load_base_recipe_types[test_case2] fails with URLError: no host given. training_session/test_submitter.py::test_close_returns_the_loops_descriptors_rather_than_waiting_for_gc counts file descriptors (assert 15 == 16) under -n 8 and passes alone. The 59 sagemaker-serve tests import torch, tensorflow, or tritonclient, or read JumpStart specs from S3 with expired host credentials (ExpiredToken). The codestyle-doc-tests (sagemaker-core) hook, tox -e flake8,pylint,docstyle,black-check, is green on this branch: black --check ./ leaves 522 files unchanged and pydocstyle src/sagemaker is clean. The hook failed on master and on every open PR: the generator emitted two blank lines before logger and no module docstring in config_schema.py and shape_dag.py. The fix is in the generator, so the next botocore sync keeps the files clean. The unit-tests (sagemaker-serve) hook failed on pandas 3.1.0 (ImportError: C extension: None not built). The merge of master brings the pandas<3.1.0 test pin from #6524.

Merge Checklist

  • I read the contribution guide.
  • The change is backward compatible.
  • The commits use the repository commit-message format.
  • Every S3 and STS client of the change takes a region.
  • The change has regression coverage and adds no dependency.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

…m ModelTrainer.train()

`ModelTrainer.train()` returned `None` and kept the created job on the private
`_latest_training_job`. A caller that needed the job name or ARN after
`train(wait=False)` had to read the private attribute or page
`ListTrainingJobs` by name prefix. The SDK's own docs, seven example
notebooks, and `ModelBuilder` read the private attribute too, and `DPOTrainer`
stored its job on a different attribute than the other serverless trainers, so
`show_metrics()` and `stream_logs()` could not find a DPO job.

Add a public `latest_training_job` property with a typed setter on
`ModelTrainer` and `BaseTrainer`, return the created `TrainingJob` from
`ModelTrainer.train()` for a SageMaker training job (`None` for a dry run, a
pipeline capture, and local mode), store the DPO job on the shared attribute,
and point the docs and error messages at the public name.

---
X-AI-Prompt: How do I get the training job that ModelTrainer.train() created without reading a private attribute?
X-AI-Tool: claude-code
…s every deploy path

`deploy()` took `tags` only through `**kwargs`, so `inspect.signature` could
not validate it, and two deploy paths dropped it: the fine-tuned model package
path read only `self._tags`, and the Nova path did not read the kwarg at all.
The accumulated tags also had two shapes in one SDK. `add_tags` compared
`tag["Key"]` and raised `KeyError: 'Key'` for the `{"key": ..., "value": ...}`
form that the `sagemaker.core.resources` classes take, and raised `TypeError`
for a `sagemaker.core.shapes.Tag`, while the fine-tuned path converted the
stored tags back to lowercase by hand.

Add `tags` as a named `deploy()` parameter. Normalize every accepted form (a
`{key: value}` mapping, boto-style dicts, core-style dicts, `Tag` objects) once
in `format_tags`, and make `add_tags`, `tag_exists`, `_validate_new_tags`, and
`remove_tag_with_key` go through it. Merge the caller's tags into the builder
before dispatch so the built-model, inference-component, recommendation,
fine-tuned, restricted-package, and Nova paths all tag the endpoint, and
replace the two hand-written case conversions with one helper.

---
X-AI-Prompt: Why does ModelBuilder.deploy drop my tags on a fine-tuned model, and why does add_tags raise KeyError: 'Key'?
X-AI-Tool: claude-code
…isted hub content

`HubContent` had no `get_all`. The resource generator skips a List API when
the list summary lacks a member the Describe input requires, and
`HubContentInfo` omits `HubName`, the name the list call was made with. So
every caller paged `list_hub_contents` on the boto3 client by hand: six places
in the SDK and three scripts in the SageMaker agent skills repeat the same
loop. `HubContentInfo` also carries `OriginalCreationTime`, the model-level
catalog date, which `DescribeHubContent` does not return, and the `HubContent`
class had no field for it, so `ResourceIterator` dropped it. The same gap made
`HubContent.get_all_versions()` build each version without `hub_name`, so the
first `refresh()` failed with `Missing required parameter in input: "HubName"`.

Teach the generator two per-resource tables: list-input members to copy onto
every listed resource (`HubName` -> `hub_name`), and summary-only members to
keep as optional attributes (`OriginalCreationTime`). `ResourceIterator` takes
the copied members as `extra_init_data`. `resources.py` is the regenerated
output: `HubContent.get_all(hub_name, hub_content_type, name_contains,
max_schema_version, creation_time_before, creation_time_after, sort_by,
sort_order, session, region)`, the `original_creation_time` attribute, and the
`hub_name` binding on `get_all_versions()`.

---
X-AI-Prompt: Why does sagemaker-core have no HubContent.get_all, and why does get_all_versions() fail on refresh with a missing HubName?
X-AI-Tool: claude-code
… and document

A hub model's task, license, provider, size, context window, languages,
modalities, model type, capabilities, and recipes live in
`HubContentSearchKeywords` as prefixed strings (`@task:`, `@license:`,
`@recipe:finetuning_sft_lora`, ...) and in the `HubContentDocument` JSON. The
`HubContent` resource exposed the two raw attributes and nothing else, so
`jumpstart/search.py`, `recipe_utils.py`, and `training_session/_utils.py` each
split the keywords for the one field they use, and the SageMaker agent skills
repeat the full parse in four scripts. The hub writes at most 50 keywords per
model, so a model with many languages loses its `@model-size:` and
`@context-window:` keywords while the document still carries the values. No
consumer handled that.

Add `HubContentMixin` in `sagemaker.core.hub_content` and teach the resource
generator a `RESOURCE_MIXINS` table so `resources.py` emits
`class HubContent(HubContentMixin, Base)`. The mixin adds no model field and
no state. Its properties read `hub_content_search_keywords` and
`hub_content_document` at call time: a keyword is authoritative and the
document fills what the cap dropped, so the same resource answers before and
after `refresh()`. `HubContentRecipe` parses `@recipe:` keywords and
`RecipeCollection` entries into type, technique, and strategy, and the resource
answers `supports_technique("sft")` and `fine_tuning_techniques()` directly.

---
X-AI-Prompt: How do I read a hub model's license, size, and supported fine-tuning techniques from HubContent without parsing the search keywords myself?
X-AI-Tool: claude-code
A caller that wants the job name or ARN of a long run has two choices today.
`train(wait=True)` returns after the job ends, so the handle arrives hours
late. `train(wait=False)` returns the job at once, but the caller then has to
wait on it alone, and the shared fine-tuning wait lives in the private
`sagemaker.train.common_utils.trainer_wait.wait`. Five SageMaker agent skill
templates import that private function for this one reason.

Add `on_submit: Optional[Callable[[TrainingJob], None]]` to `BaseTrainer.train`,
`ModelTrainer.train`, and every trainer `train()` override. The trainer calls
it once with the created job as soon as the service accepts the job and
before any wait. A `dry_run` and a `PipelineSession` capture submit nothing
and call nothing. The serverful path submits through `ModelTrainer` first and
waits on the returned job afterwards, so the callback sees the job before the
wait on that path too. `_record_submitted_job` stores `latest_training_job`
and fires the callback in one place, so the serverless, serverful, and
HyperPod paths cannot drift.

---
X-AI-Prompt: How do I get the training job name or ARN right after submit when I call train(wait=True)?
X-AI-Tool: claude-code

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 AI code review

This PR adds four public handles the SDK previously lacked: HubContent.get_all() plus a HubContentMixin with typed catalog accessors, a code-generator mechanism (RESOURCE_MIXINS, LIST_SUMMARY_EXTRA_ATTRIBUTES, GET_ALL_INPUT_ATTRIBUTES) that produces them, a public latest_training_job property/setter and a return value from ModelTrainer.train(), and a ModelBuilder.deploy(tags=...) parameter backed by unified tag normalization (_normalize_tag/format_tags). The implementation is careful and well tested, with new regression tests for every change, and I found no correctness or compatibility regressions — the format_tags rewrite still satisfies the existing test_common_utils.py cases. Only a few minor doc/annotation nits below.

Reviewed commit d352fe5. Automated review; verify before acting.


Raises:
botocore.exceptions.ClientError: This exception is raised for AWS service related errors.
The error message and error code can be parsed from the exception as follows:

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: The get_all docstring documents max_results and next_token parameters, but neither exists in the method signature (ResourceIterator handles pagination internally). These stray entries will confuse readers; drop them from the docstring.

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.

Dropped. The generator now excludes the paging members from every get_all docstring as it excludes them from the signature (ResourceIterator pages for the caller), which removes the two stray lines here and 161 more across resources.py. Commit a20a1e6.

}


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.

tags = kwargs.get("tags", getattr(self, "_tags", None))
if "tags" in kwargs:
self._tags = tags
if "tags" in kwargs and tags is not 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: _deploy_core_endpoint now calls self.add_tags(tags) here, and then the pre-existing block at lines 4001-4005 calls self.add_tags(tags) again on the same (already-merged) tags before re-reading self._tags. It's harmless because _validate_new_tags de-dups by key, but the redundant merge is dead work; the earlier block could just set tags without re-adding.

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.

Removed the second merge. _deploy_core_endpoint now merges the kwarg tags once and reads format_tags(self._tags) for every request. Commit a20a1e6.

… an unknown kwarg

`deploy()` took `data_capture_config`, `endpoint_type`, and
`inference_component_name` through `**kwargs`, and only the core endpoint
path read them, so a fine-tuned or Nova deploy dropped `data_capture_config`
without a word. `fallback_instance_types` and `tracing_config` were read
nowhere, and a misspelled option deployed an endpoint without it. The
`update_endpoint` path passed `create_endpoint_config` eleven keyword
arguments the method does not take.

Make the five options named parameters, validated once against
`inference_config` in `_EndpointOptions`, and applied on every path:

- `data_capture_config` reaches the endpoint configuration of a single-model,
  serverless, async, fine-tuned, restricted, and Nova deploy. An
  inference-component path raises `ValueError`, because the service rejects
  data capture there.
- `fallback_instance_types` builds `InstancePools` with `instance_type` at
  priority 1 and up to 4 fallbacks after it. `session_helper.production_variant`
  takes `instance_pools` and emits `InstancePools` in place of `InstanceType`,
  as the service contract requires.
- `tracing_config` reaches `CreateEndpointConfig` through the session, which
  raises `ValueError` when the installed botocore service model has no
  `TracingConfig` member instead of letting botocore drop it. The generated
  `EndpointConfig.create` gains the member once sagemaker-core is regenerated
  from a service model that has it.
- `endpoint_type` and `inference_component_name` keep their meaning and now
  raise on a path that cannot apply them.
- A kwarg outside `DEPLOY_KWARGS` raises `TypeError` before any service call.
- `update_endpoint` creates the new endpoint configuration from the built
  production variant with the arguments `create_endpoint_config` takes.

---
X-AI-Prompt: Why did ModelBuilder.deploy(data_capture_config=...) deploy my fine-tuned model without data capture, and how do I set fallback instance types?
X-AI-Tool: claude-code
The V3 structured-configuration example passes `use_spot_instances=True` and
`max_wait_time_in_seconds=7200` to `Compute`. `Compute` forbids extra fields,
so the example fails on 3.27.0 with a pydantic `extra_forbidden` error. The
spot flag is `Compute(enable_managed_spot_training=True)` and the wait limit
lives on `StoppingCondition(max_wait_time_in_seconds=7200)`, next to
`max_runtime_in_seconds`.

---
X-AI-Prompt: Why does the migration guide's Compute(use_spot_instances=True) example fail with extra_forbidden?
X-AI-Tool: claude-code
… API

The service indexes hub content for `Search(Resource="HubContent")` and
botocore 1.43.100 added the typed `SearchRecord.HubContent` record, but the
SDK never read it. A caller who wanted the public hub narrowed by license,
provider, task, or model size either walked every model with
`ListHubContents` or wrote the `Search` request, the `Metadata.<Field>` names,
the 20-filter and 1024-character limits, the paging, and the result parsing by
hand. The SageMaker agent skills carry a 955-line adapter for exactly this.

Add `HubContent.search(hub_name, filters, hub_content_type, session, region)`
to `HubContentMixin`. It builds the `And` expression of `HubName`,
`HubContentType`, and the caller's `HubContentFilter` entries, sorts by
`HubContentName` so the pages are stable, pages until the service returns no
`NextToken` or repeats one, and yields each record as a `HubContent` bound to
the session and region, with its keywords and document. `HubContentFilter`
holds one service filter. `HubContentFilter.metadata("license", "apache-2.0")`
maps a `HubContent` attribute name to its `Metadata.<Field>` name, and a
sequence of values selects the `In` operator. The filter checks the operator,
the value length, and the filter count against the service limits before the
request.

---
X-AI-Prompt: How do I search the SageMaker public hub for Apache-2.0 models from Meta without listing every model?
X-AI-Tool: claude-code
Commit 6e9df41 made `format_tags` normalize every tag through `_normalize_tag`,
which raised `TypeError` for a `(key, value)` tuple. `Processor(tags=[("Key",
"Value")])`, `ModelBiasMonitor(tags=...)`, and the feature processor
`to_pipeline(tags=[(key, value)])` callers pass pairs, and three unit tests in
`test_processing.py` and `test_clarify_model_monitoring.py` failed with it.
Accept a two-item tuple or list as one tag.

---
X-AI-Prompt: Why does Processor(tags=[("Key", "Value")]) raise TypeError after the deploy(tags=) change?
X-AI-Tool: claude-code
Callers that want training-loss values, not a PNG, set the MLflow tracking URI from
`training_job.mlflow_config.mlflow_resource_arn` and call `MlflowClient().get_metric_history`
with `training_job.mlflow_details.mlflow_run_id` themselves. The SDK repeated those lines in
`plot_training_metrics` and `get_available_metrics`, and each caller copied the recipe-specific
metric names (`total_loss` for LLMFT recipes, `train/loss` for Verl recipes).

`get_metric_history` returns `Dict[str, List[MetricPoint]]` sorted by step. It accepts a job
object or a job name, defaults to every logged metric, and resolves caller names through
`DEFAULT_METRIC_ALIASES` (`loss`, `val_loss`), first logged candidate wins. An unknown metric
raises `ValueError` that lists the metrics the run has. The tracking-URI and run-id resolution
now lives in one private helper that the three public functions share. The lazy `__getattr__`
in `sagemaker.train` exports `get_metric_history` and `MetricPoint`, and loses its duplicate
`plot_training_metrics` through `get_mlflow_url` branches.

---
X-AI-Prompt: How do I read the training loss history of a SageMaker training job as data instead of a plot?
X-AI-Tool: claude-code
…time

`verify_reward_function` accepted any dict as a sample, sent every sample in one invocation,
and selected the response format only through `is_nova`. The evaluation custom-scorer
contract differs: one invocation per sample, with `id`, `model_response`, `query`,
`response`, and `reference_answer` fields, and the `statusCode` and `body` envelope for Nova
too. The function and `RewardFunctionOutput` were importable only from
`sagemaker.train.common_utils.rlvr_reward_verifier`. `Evaluator.create` deployed a reward
Lambda from a file with `python3.9` (`EVALUATOR_DEFAULT_RUNTIME`) while
`CustomAgentLambda.create` carried its own `python3.12` literal.

`RewardSample` and `EvalRewardSample` are the typed inputs. The `envelope` option (`oss`,
`nova`, `eval_oss`, `eval_nova`) selects the request shape, the response shape, and the
sample model, and `None` keeps the `is_nova` behavior. The options live in
`RewardVerificationOptions`, which the function takes as an object or as keyword arguments,
so `verify_reward_function(reward_function, sample_data, is_nova=False)` still works.
`sagemaker.train` exports `verify_reward_function`, `RewardVerificationOptions`,
`RewardFunctionOutput`, `RewardSample`, and `EvalRewardSample`. `EVALUATOR_DEFAULT_RUNTIME`
is `python3.12`, `Evaluator.create(runtime=...)` reaches the Lambda create call, and
`CustomAgentLambda.create` defaults to the same constant.

---
X-AI-Prompt: How do I test my evaluation custom-scorer Lambda locally with the exact payload the evaluation sends, and why does Evaluator.create deploy python3.9?
X-AI-Tool: claude-code
`LLMAsJudgeEvaluator._validate_custom_metrics_json` only parsed the JSON. A metric whose
`instructions` had no `{{prompt}}` or `{{prediction}}`, an array of 11 metrics, a `ratingScale`
`definition` of 101 characters, or a `value` with neither or the two of `floatValue` and
`stringValue` reached the Bedrock evaluation job and failed there, after the pipeline started.

`LLMAsJudgeEvaluator.validate_custom_metrics(custom_metrics)` is a static method that returns
every violation as a message, an empty list means valid. It accepts the list or its JSON
string. The rules: 1 to 10 metrics, each with a `customMetricDefinition` that has a string
`name`, `instructions` of at most 5000 characters that contain `{{prompt}}` or `{{prediction}}`,
and an optional `ratingScale` whose entries have a `definition` of at most 100 characters and
a `value` with exactly one of `floatValue` or `stringValue`, one value type across the scale.
`_validate_custom_metrics_json` calls it and raises `ValueError` with the joined messages, so
`evaluate()` stops before any upload. Three existing tests passed `[{"name": "test"}]` as a
valid payload and now use a valid metric.

---
X-AI-Prompt: Can the SDK check my LLM-as-Judge custom metrics JSON before I start the evaluation job?
X-AI-Tool: claude-code
…ad metadata

`S3Downloader.read_bytes` downloaded the whole object through
`s3_resource.Bucket(bucket).download_fileobj`, so a caller that wanted the
first megabyte of a dataset, one line at a time, or the object size had to
drop to boto3. Fifteen SageMaker agent skill scripts split `s3://` URIs by
hand, eight call `get_object(Range=...)`, and four call `head_object`, each
without the `ExpectedBucketOwner` spot check that the SDK applies on the
default bucket.

Add `byte_range=(start, end)` to `read_bytes`, inclusive like an HTTP `Range`
header, served through `get_object`. Add `iter_lines`, which decodes
`Body.iter_lines()` and never holds the whole object, and `head`, which
returns a frozen `S3ObjectHead(content_length, last_modified, etag,
content_type)` from `head_object`. The three methods share `parse_s3_url`,
the session's `s3_client` with the same fallback as `Session.read_s3_file`,
and the `_get_account_id_if_default_bucket` spot check. A negative start or
an end before the start raises `ValueError` before any request.

---
X-AI-Prompt: How do I read the first megabyte of an S3 object or stream its lines with the SageMaker SDK instead of boto3?
X-AI-Tool: claude-code
`DatasetFormatDetector.validate_dataset` answered only `True` or `False` for
a local path. It stopped at the first line, named no format, read no `s3://`
source, and knew none of the Nova RLVR, PPO, and CPT shapes. Two SageMaker
agent skill scripts filled the gap with their own detectors (11 and 9 format
names, their own `s3://` parsing, their own `get_object(Range=)` call), and
the two disagree on names for the same shape.

Add `detect_dataset_format(source, options)` in `dataset_format_detector.py`.
It reads at most `sample_size_bytes` (1 MiB default) through
`S3Downloader.read_bytes(byte_range=)` for `s3://` or from the local file,
drops the last partial line, and matches every complete line against
`FORMAT_MATCHERS`: the 7 SDK JSON schemas through `jsonschema`, plus
`nova_rlvr`, `nova_ppo`, and `nova_cpt` as small pure functions. The frozen
`FormatDetectionResult` carries the format, the matched-line ratio, the
sampled line count, `"<line>: <reason>"` entries for the lines that failed,
and every candidate with its ratio. `strict=True` demands a full match.
`strict=False` picks the best candidate above a 0.5 ratio. `.parquet`,
`.csv`, and `.json` keep the detector's whole-file checks.
`SKILL_FORMAT_ALIASES` maps the 19 skill names onto the matcher names.
Export the function, the options, and the result from `sagemaker.ai_registry`.

---
X-AI-Prompt: How do I find out which fine-tuning format a JSONL dataset in S3 uses before I create a DataSet from it?
X-AI-Tool: claude-code
…ndle

Problem: the skill templates call bedrock and bedrock-runtime by hand around BedrockModelBuilder. get_bedrock_candidates.py pages list_foundation_models and list_inference_profiles and joins the two on models[].modelArn. deploy-oss-bedrock.py loops invoke_model for 12 minutes on ModelNotReadyException after the import job completes. deploy-nova-bedrock.py calls converse with the deployment ARN. The SDK opens three Bedrock clients and lists nothing and invokes nothing. deploy() returns a response dict, so the caller picks the ARNs out of it by hand.

Solution: add sagemaker.serve.bedrock_deployment. list_invocable_foundation_models() pages ListFoundationModels filtered by output modality and ListInferenceProfiles filtered to SYSTEM_DEFINED, then joins profile ids to model ids. It keeps a model when inferenceTypesSupported holds ON_DEMAND or an active cross-region profile routes to it. BedrockDeployment.wait_until_ready() probes Converse for a custom model deployment and InvokeModel for an imported model. It retries only ModelNotReadyException and raises a TimeoutError that names the ARN. invoke() does a JSON round trip, and converse() passes inferenceConfig and system through. BedrockModelBuilder.deploy() keeps its return value and sets deployment on every successful path (importedModelArn on the OSS path, modelArn plus customModelDeploymentArn on the Nova path). latest_deployment() raises ValueError before the first deploy(). Field names come from the botocore 1.43.118 bedrock and bedrock-runtime service models.

Tests: sagemaker-serve/tests/unit/test_bedrock_deployment.py fails on master with ModuleNotFoundError: No module named 'sagemaker.serve.bedrock_deployment'. On this branch test_bedrock_model_builder.py and test_bedrock_deployment.py report 124 passed.

---
X-AI-Prompt: Can BedrockModelBuilder list the Bedrock models I can invoke, wait until an import is ready, and invoke it without raw boto3 calls?
X-AI-Tool: claude-code
ModelBuilder held two private copies of the same EC2 lookup. `_get_instance_resources` and `_infer_accelerator_count_from_instance_type` each created an EC2 client, stripped the `ml.` prefix with `str.replace`, called `describe_instance_types` for one name, and read a different slice of the response. No public SDK function listed the SageMaker instance type names or described their hardware, so every caller outside ModelBuilder wrote its own EC2 call.

`sagemaker.core.instance_types` gains `InstanceSpec`, `list_instance_types`, `describe_instance_type`, and `describe_instance_types`. `list_instance_types` reads the `ProductionVariantInstanceType` enum of the loaded SageMaker service model and makes no request. `describe_instance_types` strips the `ml.` prefix, sends at most 100 names per EC2 request (the `RequestInstanceTypeList` limit), reads every page, and maps vCPUs, memory, GPU count, GPU memory, and GPU name. EC2 rejects a whole request with `InvalidInstanceType` when one name does not exist, so the function then asks for each name alone. An unknown type gets `gpu_count=0`, `None` elsewhere, and a warning. Every other `ClientError` propagates.

The two ModelBuilder helpers call `describe_instance_type` with the session's `boto_session` and keep their signatures and fallbacks: `(None, None)` and `None` on an unknown type or a failed call. The existing `test_get_instance_resources` stub now carries `InstanceType`, which the real EC2 response always holds and the mapper keys on. The module joins the `sagemaker-core` API reference.

---
X-AI-Prompt: Can sagemaker-core list and describe SageMaker instance types, so ModelBuilder drops its two private EC2 lookups?
X-AI-Tool: claude-code
`DescribeModelPackageGroup` reports a missing group as a `ValidationException`
whose message ends in `does not exist`, not as a `ResourceNotFound` error, and
`ModelPackageGroup` offered only the generated `get` and `create`. So every
"use the group, or create it" flow parsed `ClientError` codes by hand. Ten
fine-tuning templates in the SageMaker agent skills wrap `create` in a `try`
block and match `e.response["Error"]["Code"]` against
`("ResourceInUse", "ValidationException")` to fall back to `get`, which also
treats an unrelated validation error as "the group exists".

Add `sagemaker.core.model_package_group.ModelPackageGroupMixin` and attach it
to the generated class through the `RESOURCE_MIXINS` table, so `resources.py`
emits `class ModelPackageGroup(ModelPackageGroupMixin, Base)`.
`get_or_create(name, spec=None, session=None, region=None)` calls the
generated `get`, and on a `ValidationException` whose message says the group
is absent, or on a code that starts with `ResourceNotFound`, calls the
generated `create` with the set fields of a `ModelPackageGroupSpec`
(description, tags, managed configuration). Every other error propagates. The
mixin adds no model field and no state, and the returned group keeps the
session and region it was loaded with.

---
X-AI-Prompt: Can ModelPackageGroup get or create a group in one call instead of parsing ClientError codes?
X-AI-Tool: claude-code
…cessor._start_new

`Processor._start_new` built `ProcessingJob(**transformed)` from the create
request. The job carried no `processing_job_arn`, no `processing_job_status`,
and no client context, so `processor.latest_job.refresh()` built its client
from the process default session and polled the default region (aws#5796). Four
processing templates in the SageMaker agent skills call
`describe_processing_job` on a boto3 client after `run()` to read the ARN.

After the submit through the configured session, bind that session and its
region to the job with `_set_client_context` and describe the job once with
`refresh()`, so the returned job has its ARN and status and every later
`refresh()`, `wait()`, and `stop()` talks to the account and region the job
was created in. The `PipelineSession` path still returns the captured step
arguments with no describe call. A `LocalSession` job exists only in the
session's `LocalSagemakerClient`, so that path returns the request-built job
as before and `run()` keeps polling it through the local client.

---
X-AI-Prompt: Why does processor.latest_job have no ARN and refresh() poll the wrong region after run()?
X-AI-Tool: claude-code
A generated `wait()` raised `FailedStatusError` only for a status that contains
`failed`. `Stopped` is a terminal state too, so `TrainingJob.wait()` returned on
a stopped job as if the job completed, and the caller had to read
`training_job_status` after the wait to tell the two apart. Three training
templates in the SageMaker agent skills do that and raise on `Stopped`
themselves.

Add `raise_on_stopped: bool = False` after `timeout` to `WAIT_METHOD_TEMPLATE`
and a `STOPPED_STATUS_ERROR_TEMPLATE` block that the generator formats with the
same failure-reason reference as the failed block. With
`wait(raise_on_stopped=True)` a status that contains `stopped` raises
`FailedStatusError` with the resource type, the status, and the failure reason.
The default keeps the old return. Regenerated `resources.py`: all 15 `wait()`
methods take the parameter.

---
X-AI-Prompt: Can TrainingJob.wait() raise when the job is stopped instead of returning like it completed?
X-AI-Tool: claude-code
When an endpoint misbehaves, an operator reads the endpoint status and
failure reason, the per-variant instance type and counts, the 11 CloudWatch
metrics of the endpoint dashboard, and the latest lines of the
`/aws/sagemaker/Endpoints/<name>` log group. `Endpoint` exposed none of that
beyond `refresh()`, so the SageMaker agent skill `collect_diagnostics.py`
rebuilt the collection with raw boto3 clients from a region string. That
script reads `InstanceType` from `ProductionVariantSummary`, where
`DescribeEndpoint` never returns it, so the instance type is always `None`.
It fetches one unpaged page of 100 log events, and it loses the session a
caller bound the resource with.

Add `EndpointMixin` in `sagemaker.core.endpoint` and register it in the
generator's `RESOURCE_MIXINS` table, so `resources.py` emits
`class Endpoint(EndpointMixin, Base)`. `diagnostics()` refreshes the
resource, reads the instance type per variant from
`EndpointConfig.get(endpoint_config_name)` matched by variant name, sends
the 11 dashboard queries in one `GetMetricData` call keyed
`"<MetricName>/<Stat>"`, and collects the log lines. A CloudWatch, Logs, or
endpoint config failure lands in `errors` instead of raising. Only
`DescribeEndpoint` raises. `recent_logs()` pages `FilterLogEvents` until
the next token is absent, the page is empty, or the limit is reached, and
formats `"<ISO timestamp> <log stream> <message>"` lines. The CloudWatch and
Logs clients come from the session, region, and config the resource is bound
to, so `Endpoint.get(name, session=..., region=...)` reads the metrics and
logs of that account and region. The mixin adds no model field and no state.

---
X-AI-Prompt: How do I get the status, metrics, and recent logs of a SageMaker endpoint from the Endpoint resource without writing my own boto3 calls?
X-AI-Tool: claude-code
HyperparameterTuner builds a HyperParameterTrainingJobDefinition, which has no serverless_job_config, so AMT cannot run SFTTrainer, DPOTrainer, RLVRTrainer, or RLAIFTrainer trials. Users wrote their own sweep loops with a JSON manifest, a thread pool, and MLflow polling.

FineTuningSweep runs a trainer whose train(wait=False) returns a TrainingJob or sets latest_training_job. SweepConfig selects a grid, random, or ASHA schedule with a bounded number of parallel jobs. ASHA reads the rung metric from the job's MLflow run and stops the bottom 1 - 1/reduction_factor share of each batch. With manifest_s3_uri the sweep writes its state to S3 after every trial change, and resume() skips terminal trials and attaches to running jobs by name. Trials poll training_job_status instead of TrainingJob.wait(), because rich 13 permits one live display per console and parallel waits raise LiveError.

---
X-AI-Prompt: How can I run a hyperparameter sweep for SFTTrainer when HyperparameterTuner only supports ModelTrainer?
X-AI-Tool: claude-code
@evakravi
evakravi deployed to auto-approve October 26, 2026 03:30 — with GitHub Actions Active

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 AI code review

This large PR adds sixteen public SDK surfaces across sagemaker-core and sagemaker-train: HubContent metadata/search/caching helpers, EndpointMixin diagnostics, ModelPackageGroupMixin.get_or_create, instance-type lookups, a reworked format_tags, trainer latest_training_job/on_submit handles, metric history, reward-function envelopes, FineTuningSweep, and ModelBuilder.deploy options. The code is well-structured (hand-written behavior isolated into mixin modules) and ships extensive unit tests. Overall it looks solid; my comments are a correctness subtlety in the telemetry-decorated generator and a couple of minor documentation/robustness notes.

Reviewed commit 8f9ea7f. Automated review; verify before acting.

hub_name: str,
filters: Optional[Sequence[HubContentFilter]] = None,
hub_content_type: str = "Model",
session: Optional[Any] = 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: search is a generator function (it yields), but it is wrapped by @_telemetry_emitter. The telemetry wrapper calls response = func(*args, **kwargs) and measures latency/success around that call — for a generator this returns immediately without running the body, so every search is recorded as a near-zero-latency success and any exception raised during iteration (including the ValueError from _search_expression/to_request) is never seen by the telemetry path. It also means the documented Raises: ValueError for bad filters is deferred until the caller starts iterating, not raised from the search() call itself, unlike the eager validation in get_all_cached. Consider validating the expression eagerly (e.g. build _search_expression before entering the generator, or split into an eager wrapper + inner generator) so the telemetry and the documented Raises behave as described.

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 a materialized list, see the thread on the search signature. Commit a081545.

"""Typed accessors over ``hub_content_search_keywords`` and ``hub_content_document``.

Mixed into the generated ``sagemaker.core.resources.HubContent``. Every accessor reads
the two attributes at call time, so a ``refresh()`` that fills the document changes the

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: The docstring example HubContentFilter.metadata("license", "apache-2.0") uses a lowercase value, but the team precedent and migration.md in this same PR note that Metadata.<Field> values are case-sensitive and must match the document spelling exactly (Apache-2.0). The docs/training/index.rst example here also uses apache-2.0 while migration.md uses Apache-2.0. Aligning these examples would avoid users building filters that silently return nothing.

Source: #6533

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.

Aligned on the document spelling, see the thread on docs/training/index.rst. Commit a081545.

Returns:
dict[str, InstanceSpec]: One ``InstanceSpec`` per requested name, keyed by that name.

Raises:

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: describe_instance_types builds the EC2 client with _boto_session(session).client("ec2") and no region. When neither the passed session nor the environment supplies a default region, boto3 raises NoRegionError here, but unlike list_instance_types the docstring's Raises: section does not mention it. Either pass a region/document the behavior for consistency with list_instance_types.

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.

Added region to describe_instance_types and describe_instance_type, passed to the EC2 client as list_instance_types does, and NoRegionError to the Raises sections. Test test_describe_instance_types_without_a_region_raises_no_region_error. Commit a081545.

…t cache shape

`HubContent.get_all_cached` kept its own cache design: a file per hub read on
every call, a `max_age` argument, and no in-memory layer. The JumpStart models
cache (`sagemaker.core.jumpstart.cache.JumpStartModelsCache`) is the SDK
precedent for cached catalog metadata. It is an `LRUCache` from
`sagemaker.core.utilities.cache` with a bounded item count (20) and an
expiration horizon (6 hours). The `LRUCache` calls a retrieval function on a
miss, or on an expired entry with the old value. A static
`JumpStartModelsAccessor` holds the process cache and takes new kwargs
through `set_cache_kwargs`.

Rebuild `HubCatalogCache` on that shape. The `LRUCache` holds one entry per
hub and content type with the JumpStart defaults. The retrieval function lists
the hub and reuses every item whose `HubContentVersion` the list still
reports. The rest are described 4 at a time. The JumpStart retrieval function
reads the manifest again only when its ETag changed, in the same way. The
described items
persist to one JSON file per hub, content type, region, and account, so a new
process starts from the previous catalog. `HubCatalogAccessor` holds the
process cache, and `HubContent.set_cache_kwargs(cache_expiration_horizon=...,
max_cache_items=..., max_workers=..., cache_dir=...)` rebuilds it. The `LRUCache`
is not thread safe, so its reads and writes run under one lock, and a retrieval
runs outside it under a per-key lock: a slow hub blocks no hit on another hub,
and two threads that miss the same hub share one list-and-describe pass.

`LRUCache.put` on a present key read `curr_value.element` from an `Element`
whose attribute is `value` and raised `AttributeError`. The JumpStart cache
never reached that line, because it refreshes through `get`. The fix reads
`curr_value.value`, with a regression test.

Measured on the public hub (766 models): the first call takes 59.6 seconds
(8 list calls, 766 describes). A call inside the horizon takes 0.3 seconds
with no request. `refresh_cache` on the unchanged hub takes 1.6 seconds (8
list calls, 0 describes). A new process reads the 32.9 MB file in 2.5 seconds.

---
X-AI-Prompt: Is the hub content cache in line with how the SageMaker SDK caches JumpStart model metadata?
X-AI-Tool: claude-code
@evakravi
evakravi deployed to auto-approve October 26, 2026 06:27 — with GitHub Actions Active
@evakravi
evakravi deployed to auto-approve October 26, 2026 06:27 — with GitHub Actions Active
@evakravi
evakravi deployed to auto-approve October 26, 2026 06:27 — with GitHub Actions Active

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 AI code review

This large PR adds many public SDK surfaces: HubContent metadata accessors / search / cached listing (hub_content.py, hub_catalog.py), Endpoint.diagnostics/recent_logs (endpoint.py), ModelPackageGroup.get_or_create, instance-type lookups, BedrockDeployment, typed ModelBuilder.deploy() options, a rewritten format_tags, BaseTrainer.latest_training_job as a validating property with on_submit, and session-helper tracing/instance-pool support. The implementation is consistent with existing patterns (JumpStart cache shape, resource mixins) and is backed by extensive unit tests. Overall the code is high quality; I found one telemetry-correctness issue on the generator-based search, plus two lower-severity notes worth the author's time.

Reviewed commit f529db2. Automated review; verify before acting.

"""``True`` when the hub tags the model with the ``customization`` capability."""
return CUSTOMIZATION_CAPABILITY in self.capabilities

@property

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: HubContent.search is a generator function (it uses yield), but it is wrapped with @_telemetry_emitter. The telemetry wrapper calls func(*args, **kwargs) and treats the result as the finished return value. For a generator function that call returns a generator object immediately without executing any of the body, so:

  • the telemetry event is recorded as SUCCESS with ~0 latency before any Search API call is actually made;
  • any ClientError/ValueError raised while the caller iterates (e.g. a bad filter from to_request(), or _search_expression exceeding MAX_SEARCH_FILTERS) happens after the wrapper has already returned, so it is never captured by the FAILURE path.

The search itself still works, so this is telemetry correctness only. Consider making search return a materialized list (as get_all_cached does) or wrapping the eager validation (_search_expression) in a non-generator entry point so the decorated call executes the request-building work. get_all_cached/refresh_cache return lists and are unaffected.

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 a materialized list, see the thread on the search signature. Commit a081545.

environment: Optional[Dict[str, str]] = None
training_image: Optional[str] = None
latest_training_job: Optional[TrainingJob] = 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: latest_training_job changes from a plain class attribute (Optional[TrainingJob] = None) to a property with a setter that raises TypeError unless the value is a TrainingJob or None. This is a behavioral/breaking change for any consumer that previously assigned a job name string (the old docs and the removed model_customization.rst snippet did exactly trainer._latest_training_job = "my-hp-job"). The docs in this PR are updated to use TrainingJob.get(...), so the intent is clear, but it's worth calling out in the migration notes since the failure mode (TypeError at assignment) is new.

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.

Documented: migration.md has a row for attaching a trainer to an existing job (trainer.latest_training_job = TrainingJob.get(job_name)), with the setter contract (a TrainingJob or None, TypeError for a job name string). Commit a081545.

"""Drop the in-memory entries, and the cache file of ``key`` when one is given.

Returns ``True`` when a file was deleted.
"""

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: _previous_value reaches into self._content_cache._lru_cache.get(key). Note that when HubCatalogCache.get() first calls self._content_cache.get(key, data_source_fallback=False) on an expired entry, LRUCache._get_item pops the key before raising KeyError and does not put it back, so by the time _previous_value runs the in-memory entry is already gone and this returns None. The retrieval then falls back to cache_file.load(), so correctness is preserved, but the in-memory "describe only changed items" shortcut is effectively never taken for expired entries — every expired-in-memory refresh re-reads the JSON file. If that disk read was meant to be avoidable, capture previous before the failing get() pops it.

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: get() reads the old value before the LRUCache.get call, so an expired entry refreshes from memory and describes only the changed items without the file read. Test test_an_expired_entry_reuses_the_in_memory_items_without_the_cache_file deletes the cache file before the refresh. Commit a081545.

…e default thread pool

`HubContent.get_all` described every listed item on every call, 4 at a
time through `ResourceIterator(max_workers=4)`: 766 `DescribeHubContent`
calls and 56 to 72 seconds for the public hub. The cache lived behind a
second method, `get_all_cached`, with its own signature and no list
filters, so a caller had to pick between the filters and the cache.

Make `get_all` the cached method. The generator emits no `get_all` for
`HubContent` (`HAND_WRITTEN_GET_ALL_RESOURCES`), and `HubContentMixin`
provides it with the `ListHubContents` parameters of the generated
method. The `HubCatalogCache` serves the catalog from memory inside the
6 hour horizon, from the cache file in a new process, and lists the hub
and describes only the changed items after the horizon. The list
filters (`name_contains`, `max_schema_version`, the creation time
bounds, `sort_by`, `sort_order`) apply to the cached catalog through
`HubCatalogFilters`, so a filtered call costs no request.

The describes run on a `ThreadPoolExecutor` of the host default size
(`min(32, cpu_count + 4)`) instead of a fixed `max_workers`. The
describe client retries in the botocore `adaptive` mode with a 32
connection pool (`catalog_client_config`), passed through a new `config`
argument of `Base.get_sagemaker_client`. On the public hub the default
pool failed 462 of 766 describes in the standard mode and finished with
0 failures in the adaptive mode: 60.3 s cold with 44 throttle retries,
0.4 s inside the horizon, 1.9 s in a new process, 0.2 s for a filtered
call.

`ResourceIterator` returns to its `master` shape, with no `max_workers`,
and `get_all_versions` loses the parameter.

---
X-AI-Prompt: Can HubContent.get_all cache the described hub contents by default and use the default thread pool size?
X-AI-Tool: claude-code
@evakravi
evakravi deployed to auto-approve October 26, 2026 15:57 — with GitHub Actions Active
@evakravi
evakravi deployed to auto-approve October 26, 2026 15:58 — with GitHub Actions Active
@evakravi
evakravi deployed to auto-approve October 26, 2026 15:58 — with GitHub Actions Active

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 AI code review

This large PR adds many public surfaces to sagemaker-core: HubContent.get_all/search/caching (hub_catalog.py, hub_content.py), Endpoint.diagnostics()/recent_logs() (endpoint.py), ModelPackageGroup.get_or_create(), list_instance_types/describe_instance_type, a rewritten format_tags/tag-merge path, instance_pools/tracing_config plumbing in session_helper.py, a ProcessingJob session-binding fix, and raise_on_stopped on generated wait(). I reviewed the hand-written modules in full; the generated resources.py and the test additions are mostly in the truncated portion of the 650KB diff, so I could not confirm the generator wiring (e.g. the original_creation_time field and mixin inheritance) end-to-end. The hand-written code is internally consistent. A few concrete issues below, mostly low severity.

Reviewed commit 625a4f0. Automated review; verify before acting.

) -> Iterator["HubContentMixin"]:
"""Search a hub server side and yield each matching ``HubContent``.

Runs ``Search(Resource="HubContent")`` with an ``And`` expression of ``HubName``,

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: search is a generator (it uses yield), but it is wrapped with @_telemetry_emitter. The decorator does response = func(*args, **kwargs) and then records SUCCESS/latency and returns response. For a generator function, calling it only constructs the generator object — none of the search body (the client.search(...) paging, and even the _search_expression(...) validation) runs until the caller iterates, which happens outside the decorator's try/except. So telemetry will always record immediate SUCCESS with ~0 latency and will never capture failures or the real duration of the paged search. get_all (which returns a list) is unaffected. Consider making search a non-generator that returns a list, or emitting telemetry from an inner helper that is fully consumed inside the decorated call.

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: search() returns the matching HubContent list after the last page, so the filter validation, the paging, and the telemetry run inside the decorated call. A ValueError from a bad filter is raised from search() and recorded as a failure. Test test_search_raises_from_the_call_and_emits_a_failure. Commit a081545.


@property
def output_modalities(self) -> List[str]:
"""Every ``@output-modality:`` keyword, extended with document ``OutputModalities``."""

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: get_all/search/refresh_cache take session (boto3) and region keyword arguments, not sagemaker_session. The _telemetry_emitter decorator only looks for sagemaker_session on args[0]/kwargs and otherwise falls back to _get_default_sagemaker_session(). So even when a caller passes an explicit session, telemetry is emitted against a default session, and every decorated call constructs a default session (and resolves the opt-out config) regardless of the caller's intent. Worth confirming this is acceptable for these classmethods.

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, and kept for this PR. The decorator reads sagemaker_session only, and every other session-taking classmethod in the SDK (AIRHub.list_hub_content, AIRHub.import_hub_content) has the same behavior. The default session costs about 0.2 s per call on this host (the Session constructor builds its clients), which a cache hit of get_all pays. Teaching the decorator to read a boto session is a telemetry-wide change, outside this PR.

Comment thread docs/training/index.rst Outdated
"SageMakerPublicHub",
filters=[
HubContentFilter.metadata("license", "apache-2.0"),
HubContentFilter.metadata("provider", "Meta"),

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: The HubContentFilter.metadata("license", "apache-2.0") example (and the same lowercase value in hub_content.py's HubContentFilter.metadata docstring) uses apache-2.0, while migration.md uses Apache-2.0. Per the team precedent, HubContentFilter.metadata() field names and values are case-sensitive and must match the document spelling exactly (e.g. Apache-2.0, not apache-2.0) (Source: #6533). The lowercase examples will silently return no results. Align the examples on the document casing.

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.

Aligned on the document spelling: Apache-2.0 and meta in docs/training/index.rst, the HubContentFilter.metadata docstring, the module docstring, and the tests. Against the public hub, Apache-2.0 matches 112 models and apache-2.0 none, meta matches 74 and Meta none. The metadata docstring names the rule and points a keyword value to HubContentFilter.keyword. Commit a081545.

The answer is kept per set of credentials for the life of the process, so the STS
call happens once per principal, not once per cache read.
"""
# common_utils imports resources, which imports this module, so the import runs here.

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: _catalog_resources sets resource.original_creation_time = summary["OriginalCreationTime"] directly on a pydantic model whose Base config has validate_assignment=True and extra="forbid". This relies on the generator having added an original_creation_time field to HubContent with a type that accepts the raw boto summary value (a datetime, or an ISO string when reloaded from the JSON cache file). That field addition is in the truncated generated resources.py; please confirm the field exists and its type accepts both representations, otherwise this assignment raises a validation error at get_all time.

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. The generator adds original_creation_time: Optional[datetime.datetime] = Unassigned() to HubContent (LIST_SUMMARY_EXTRA_ATTRIBUTES in resources_codegen.py, line 13857 of resources.py). The value comes from the ListHubContents summary of the current retrieval, a datetime from boto3, never from the JSON file: the file stores only the described resource. The cached resource itself is rebuilt with model_dump(mode="json") strings, which pydantic parses back into datetime. test_a_new_process_starts_from_the_cache_file asserts original_creation_time and creation_time after a reload. Commit a081545.

@aymane-omari aymane-omari self-assigned this Oct 26, 2026

@aymane-omari aymane-omari left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Comments

Comment on lines +13 to +23
"""``latest_training_job`` is the public handle for the job a trainer created.

Before this property the only handle was the private ``_latest_training_job``, so callers
that needed the job name or ARN after ``train(wait=False)`` had to read the private
attribute or page ``ListTrainingJobs`` by name prefix. ``ModelTrainer.train()`` also
returned ``None``, so the job could not be chained from the call that created it.

``on_submit`` hands the created job to the caller as soon as the service accepts it and
before any wait, so a long ``train(wait=True)`` can record the job name or ARN without the
private ``trainer_wait.wait`` import that ``wait=False`` plus a hand-rolled wait needed.
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The docstring reads more like a changelog entry than a description of what the module tests. That history is useful, but it belongs in the PR description or migration.md. Consider trimming this to what the tests cover: the latest_training_job property and setter, the return value of train(), and the on_submit ordering contract across ModelTrainer, the serverless trainers, and HyperPod.

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.

Rewritten. The module docstring states what the tests cover: the latest_training_job property and setter, the TrainingJob that train() returns, and the on_submit ordering contract across ModelTrainer, the serverless trainers, and the HyperPod path. The history moved to the PR description. Commit 4042f61.



def test_model_trainer_latest_training_job_setter_rejects_a_job_name(model_trainer):
with pytest.raises(TypeError, match="TrainingJob.get"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The match argument to pytest.raises is a regex, so the dot in TrainingJob.get matches any character. It still passes, but it is looser than it looks. Wrapping with re.escape, or matching a longer distinctive phrase from the message, would make the intent clearer. Same pattern at lines 157 and 166/168.

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: the matches are re.escape of a longer phrase from each message (Use TrainingJob.get(<name>) to attach an existing job. and trainer.latest_training_job = TrainingJob.get('<job-name>')), as module constants SETTER_ERROR and ATTACH_HINT. Commit 4042f61.

Comment on lines +211 to +212
for active in patches:
active.start()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The patch start loop runs before the try block, so if any single patch.start() raises (for example because a target attribute is renamed), the patches that already started never get stopped and will leak into every later test in the session. Moving the starts inside the try, or replacing the manual start/stop with contextlib.ExitStack, would make this safe and would also remove the repeated try/finally in both tests.

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: the serverless and HyperPod cases run under one ExitStack fixture (serverless, hyperpod_trainer), so a patch.start() that raises releases every patch already started and the manual start and stop loops are gone. Commit 4042f61.

Comment on lines +193 to +199
@pytest.mark.parametrize(
"trainer_cls, module",
[
(SFTTrainer, "sagemaker.train.sft_trainer"),
(DPOTrainer, "sagemaker.train.dpo_trainer"),
],
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This parametrize covers SFTTrainer and DPOTrainer only. On this branch rlvr_trainer.py, rlaif_trainer.py and cpt_trainer.py also take on_submit and set latest_training_job, so a regression in any of those three would not be caught here. Could they join the list (here and at line 255)? If one of them is deliberately left out, a short comment saying why would stop a reader from assuming the omission is accidental.

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: the serverless cases cover SFTTrainer, DPOTrainer, RLVRTrainer (with an evaluator ARN and skip_reward_validation), and RLAIFTrainer, in the four tests (created job, on_submit before the wait, on_submit with wait=False, dry_run). CPTTrainer is HyperPod only, so it has its own case through _train_hyperpod. Commit 4042f61.

model_trainer.train(wait=True, logs=False, on_submit=lambda job: order.append(job))

assert order == [created, "wait"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This nicely pins the ordering. One case I do not see covered anywhere: what happens when the on_submit callback itself raises? Should train() still set latest_training_job and still wait, or should the exception propagate and skip the wait? Either answer is reasonable, but it should be a deliberate choice with a test, since a user callback that fails is the most likely real-world misuse of this hook. Related: on_submit is tested with wait=True and dry_run for ModelTrainer, but not with wait=False, which the docstring names as the original motivation.

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.

Made deliberate and tested: a callback that raises propagates from train(), the job stays on latest_training_job (it is already running), and the wait is skipped (test_a_raising_on_submit_keeps_the_job_and_skips_the_wait). on_submit with wait=False is covered for ModelTrainer and the four serverless trainers. Commit 4042f61.

Comment on lines +298 to +318
def _hyperpod_sft_trainer():
trainer = SFTTrainer.__new__(SFTTrainer)
trainer.sagemaker_session = MagicMock()
trainer.compute = SimpleNamespace(
cluster_name="my-cluster",
namespace="kubeflow",
instance_type="ml.p5.48xlarge",
node_count=2,
)
trainer._model_name = "amazon.nova-lite-v2"
trainer._customization_technique = "SFT"
trainer.training_type = "LORA"
trainer.training_image = "123.dkr.ecr.us-west-2.amazonaws.com/img:latest"
trainer.base_job_name = None
trainer.training_dataset = None
trainer.validation_dataset = None
trainer.s3_output_path = "s3://bucket/out/"
trainer.hyperparameters = None
trainer._recipe_path = "recipe-name"
trainer._overrides = None
return trainer

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Building the trainer with SFTTrainer.new and then setting private attributes directly couples this test to the names _model_name, _customization_technique, _recipe_path and _overrides. A rename of any of those breaks the test without saying anything about the on_submit behavior under test. If the constructor is too heavy to call here, a small fixture that goes through the real constructor with the HyperPod validators patched would keep the test honest about the state the trainer ends up in. Also, training_type is the string "LORA" rather than TrainingType.LORA, which may not match what the constructor produces.

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: the HyperPod fixture builds the trainer through the real SFTTrainer constructor with HyperPodCompute(cluster_name, instance_type, node_count), training_type=TrainingType.LORA by default, and patches the Hub, IAM, recipe, and CLI calls at the names the modules use. No private attribute is set by hand. Commit 4042f61.

):
job_name = trainer._train_hyperpod(wait=False, on_submit=submitted.append)

assert job_name == "my-job-123"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This asserts _train_hyperpod returns the string "my-job-123", while every other path in this file asserts train() returns a TrainingJob. base_trainer.py line 765 confirms the -> str return. That means the public return type of train() depends on the compute target, which is awkward for callers who want to chain the job. Is the string intentional for HyperPod, or should it return the same TrainingJob it hands to on_submit?

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.

Fixed: _train_hyperpod returns the TrainingJob it hands to on_submit and stores in latest_training_job, so train() returns the same type on every compute target. CPTTrainer.train documents it, FineTuningSweep drops its job-name branch, the two HyperPod notebooks print training_job_name, and the three tests that asserted the string now read training_job_name. Commit 4042f61.

assert [job.training_job_name for job in submitted] == ["my-job-123"]
assert trainer.latest_training_job is submitted[0]
start_cmd = mock_subprocess.run.call_args_list[-1].args[0]
assert json.loads(start_cmd[start_cmd.index("--override-parameters") + 1])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This only checks that the parsed override-parameters JSON is truthy. With _overrides set to None and flatten_resolved_recipe mocked to return an empty dict, it is not obvious what the payload is expected to hold. Asserting a specific key or shape would make clear what regression this line protects against; if it only guards that the flag is present, a comment saying so would help.

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: the test asserts the exact payload, {"instance_type": "ml.p5.48xlarge", "container": <image>}, which are the two override parameters the trainer sends when model_source is unset. Commit 4042f61.

…and the cache refresh

The automated review of PR aws#6533 left eight open findings on the branch.

`HubContent.search` was a generator under `@_telemetry_emitter`, so the
decorator timed the construction of the generator, recorded SUCCESS before
the first `Search` call, and never saw a `ValueError` from the filter
validation. `search` now returns the matching `HubContent` list, so the
validation, the paging, and the telemetry run inside the call.

The `HubContentFilter.metadata` examples used `apache-2.0` and `Meta`,
which match no model: the service compares a `Metadata.<Field>` value with
the document spelling (`Apache-2.0` matches 112 models and `apache-2.0`
none, `meta` matches 74 and `Meta` none). The docstrings, `docs/training`,
and the tests use the document spelling, and the `metadata` docstring names
the rule and points to `HubContentFilter.keyword` for a keyword value.

`HubCatalogCache.get` read the old value after `LRUCache.get` had popped
the expired element, so an expired entry fell back to the cache file on
every refresh. The old value is read first, and a test refreshes an expired
entry with the cache file deleted.

`describe_instance_types` and `describe_instance_type` take a `region`, as
`list_instance_types` does, and the docstrings name `NoRegionError`.

`_tag_text` turns a missing tag value into the empty string instead of
`None`, because the service model marks `Tag.Value` as a necessary member.

`verify_reward_function` keeps `validate_format`, `compute`, and `is_nova`
as its third to fifth positional parameters, so a first-release call such
as `verify_reward_function(fn, data, False, None, False)` still works, and
`options` moves behind them.

`migration.md` names the `latest_training_job` setter contract: a
`TrainingJob` or `None`, with `TypeError` for a job name string.

---
X-AI-Prompt: Can you review the latest comments on PR aws#6533 and address them?
X-AI-Tool: claude-code
@evakravi
evakravi deployed to auto-approve October 26, 2026 20:41 — with GitHub Actions Active
@evakravi
evakravi deployed to auto-approve October 26, 2026 20:41 — with GitHub Actions Active
@evakravi
evakravi deployed to auto-approve October 26, 2026 20:41 — with GitHub Actions Active

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 AI code review

This large PR adds ~eighteen public surfaces to sagemaker-core: HubContent.get_all/search/refresh_cache backed by a new HubCatalogCache (modeled on the JumpStart cache), typed HubContent metadata accessors, EndpointMixin.diagnostics()/recent_logs(), ModelPackageGroup.get_or_create(), instance-type lookups, S3Downloader.read_bytes(byte_range=)/iter_lines()/head(), a raise_on_stopped param on generated wait() methods, a rewritten format_tags, and a public latest_training_job. It is wired through the code generator via RESOURCE_MIXINS/LIST_SUMMARY_EXTRA_ATTRIBUTES/GET_ALL_INPUT_ATTRIBUTES. The implementation is careful and well-documented; the generator template and the generated resources.py were kept in sync (e.g. the new config= param, _set_client_context). It also fixes a latent curr_value.element → .value bug in LRUCache.put. I found no blocking correctness issues in the areas I reviewed; the notes below are minor.
[{"path": "sagemaker-core/src/sagemaker/core/hub_content.py", "line": 300, "severity": "low", "body": "HubContent.get_all/search/refresh_cache accept session= and region=, but the _telemetry_emitter decorator only resolves a session from an instance (args[0]) or a sagemaker_session kwarg. For these classmethods args[0] is cls and the arg is named session, so telemetry always falls back to _get_default_sagemaker_session() — emitting telemetry against the default region/credentials instead of the caller's. This can trigger an extra default-session/credential resolution (and surprising failures in environments with no default region) even though the caller passed an explicit session/region. Consider forwarding the caller's session to telemetry (or exempting these methods)."}, {"path": "sagemaker-core/src/sagemaker/core/hub_catalog.py", "line": 388, "severity": "low", "body": "_describe_all yields zip(summaries, executor.map(describe, summaries)), and executor.map re-raises the first exception in submission order while consuming results in order. If an early describe raises, later describes that already completed are never consumed/stored, so the finally: cache_file.save(entries) persists only the items up to the failure. The docstring ("A describe that raises still leaves every described item on disk") overstates this. Not a correctness bug, but the comment is misleading; consider as_completed if you truly want to persist every successful describe before propagating."}]

Reviewed commit a081545. Automated review; verify before acting.

:members:
:undoc-members:

.. automodule:: sagemaker.core.instance_types

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verse uses a different repo aws:master-data-preparation right?

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.

Yes. Verse installs the preview SDK. The SageMakerAIAgentSkills build declares SageMakerPythonSDKPreview = 3.x_master_data_preparation as a build tool, and install_preview_sdk.sh installs the five wheels the preview distribution pipeline writes from that staging branch. The staging branch and public master have diverged. master-data-preparation is 66 commits ahead of and 44 behind staging master, which is identical to public master (4042a24f). The data-preparation sync PR #2345 names the plan to port the branch to a release branch off public master. This PR targets public master, so Verse gets it when the next sync carries public master into master-data-preparation (the last one, #2345 on 2026-10-26, brought staging master up to 3.27.0). The 7 files of this PR that the staging branch also touches (common_utils.py, resources.py, telemetry_logging.py, base_trainer.py, model_builder.py, and two tests) are in that sync path. I can open the port to master-data-preparation after this merges, if that is the route you want.

…iner test review

The human review of PR aws#6533 left eight threads on
`test_latest_training_job.py`, and the automated review two on the hub
content modules.

`_train_hyperpod` returned the job name string while every other path
returned the `TrainingJob`, so the return type of `train()` depended on
the compute target. It returns the `TrainingJob` it hands to `on_submit`
and stores in `latest_training_job`. `CPTTrainer.train` documents the
handle, `FineTuningSweep` drops the job-name branch, and the two HyperPod
notebooks print `training_job_name`.

The test module states what it covers instead of the change history. The
`pytest.raises` matches use `re.escape`. The serverless cases cover the
SFT, DPO, RLVR, and RLAIF trainers through one `ExitStack` fixture, so a
patch that fails to start stops nothing from being released. The HyperPod
cases build the trainer through the real `SFTTrainer` constructor with
`HyperPodCompute`, assert the exact `--override-parameters` payload, and
cover `dry_run` and `CPTTrainer`. New cases pin `on_submit` with
`wait=False` and a callback that raises: the job stays on
`latest_training_job`, the error propagates, and the wait is skipped.

`HubCatalogCache._describe_all` consumed `executor.map` in order, so an
early failure dropped the results of later workers before the file was
written. It collects with `as_completed`, stores every result, writes the
file, then raises the first failure.

`_telemetry_emitter` read only `sagemaker_session`, so a classmethod call
with a boto `session` keyword sent its telemetry on the default
credentials and built a default `Session` on every call. The decorator
wraps a `boto3.Session` passed as `session` into a `Session`, and falls
back to the default only without one.

---
X-AI-Prompt: Can you make sure the latest review comments on PR aws#6533 are addressed?
X-AI-Tool: claude-code
Brings in the three commits on master since the last merge: the vLLM
v0.30 and v0.31 image metadata (aws#6526), the DLC SCL RBA config (aws#6507),
and the MTRL SOP-Bench AgentCore example (aws#6532). No file is changed on
the two sides.

---
X-AI-Prompt: Can you refresh the PR so the latest changes from master are synced in?
X-AI-Tool: claude-code
@evakravi
evakravi deployed to auto-approve October 26, 2026 22:46 — with GitHub Actions Active
@evakravi
evakravi deployed to auto-approve October 26, 2026 22:46 — with GitHub Actions Active
@evakravi
evakravi deployed to auto-approve October 26, 2026 22:46 — with GitHub Actions Active

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 AI code review

This large PR adds eighteen public surfaces to SageMaker SDK v3: HubContent.get_all/search/cache (hub_catalog.py, hub_content.py), Endpoint.diagnostics/recent_logs (endpoint.py), ModelPackageGroup.get_or_create, list_instance_types/describe_instance_type, trainer job handles (train()/_train_hyperpod() now return TrainingJob, latest_training_job, on_submit), typed deploy options, format_tags normalization, a TracingConfig endpoint-config member, and raise_on_stopped on generated wait() methods. The code is carefully written and well-documented, with regression tests for the training-side changes. My review of the hub catalog cache, tag handling, endpoint diagnostics, instance-type helpers, and the session-helper changes found the implementation sound; a few lower-severity items are noted below. Note the diff was truncated (~675k chars); I could not fully review the ModelBuilder.deploy, Bedrock, S3 and hub-content unit-test additions, so those are not covered here.

Reviewed commit 5ea7b09. Automated review; verify before acting.

@@ -2124,38 +2186,43 @@ 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 tags
def format_tags(tags: Optional[Tags]) -> Optional[List[TagsDict]]:
"""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.

query = queries_by_id.get(result.get("Id"))
if not query:
continue
previous = series.get(query.key)

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: _boto_client constructs a fresh SageMakerClient(session=..., region_name=...) on every call, which eagerly builds the four SageMaker-family clients (sagemaker, runtime, featurestore-runtime, metrics) just to borrow the resolved session/region/config for a cloudwatch or logs client. diagnostics() calls this twice per invocation. Consider resolving the session/region/config once (or caching) to avoid repeatedly instantiating those unused clients.

@akappagan akappagan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What's the testing strategy for these changes? How do we ensure that this changes won't break Verse or production?

@evakravi

Copy link
Copy Markdown
Member Author

Closing. The change lands on the branch the Verse preview SDK is built from, aws/sagemaker-python-sdk-staging master-data-preparation, and public master picks it up through the next sync of that branch.

@evakravi evakravi closed this Oct 27, 2026

This branch was successfully deployed

1 active deployment
auto-approve — 5ea7b09d Deployed Oct 26, 2026 by evakravi via wait-for-approval #1851
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants