Skip to content

Commit a0da02f

Browse files
committed
feat: add import job polling and provisioned throughput for Bedrock OSS deployments
- deploy() for non-Nova models now waits for import job completion and returns job details (model ready for on-demand inference). - New public method: create_provisioned_throughput() with polling. - New private methods: _wait_for_import_job_complete(), _wait_for_provisioned_throughput_in_service(). - Added unit tests and integ tests (serial to avoid concurrent quota issues). - Mark bedrock integ tests as serial to avoid concurrent import job quota issues. X-AI-Prompt: add import polling and PT for bedrock OSS deployments X-AI-Tool: kiro-cli
1 parent fda2565 commit a0da02f

5 files changed

Lines changed: 696 additions & 97 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@ sagemaker_train/src/**/container_drivers/distributed.json
4444
docs/api/generated/
4545
.hypothesis
4646
.kiro
47+
bedrock_api_logs/

‎sagemaker-serve/src/sagemaker/serve/bedrock_model_builder.py‎

Lines changed: 153 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,15 @@ def deploy(
157157
For Nova models, also creates a custom model deployment for inference.
158158
159159
Args:
160-
job_name: Name for the model import job (non-Nova models only).
161-
imported_model_name: Name for the imported model (non-Nova models only).
160+
job_name: Name for the model import job (OSS models only).
161+
imported_model_name: Name for the imported model (OSS models only).
162162
custom_model_name: Name for the custom model (Nova models only).
163163
role_arn: IAM role ARN with permissions for Bedrock operations.
164-
job_tags: Tags for the import job (non-Nova models only).
165-
imported_model_tags: Tags for the imported model (non-Nova models only).
164+
job_tags: Tags for the import job (OSS models only).
165+
imported_model_tags: Tags for the imported model (OSS models only).
166166
model_tags: Tags for the custom model (Nova models only).
167-
client_request_token: Unique token for idempotency (non-Nova models only).
168-
imported_model_kms_key_id: KMS key ID for encryption (non-Nova models only).
167+
client_request_token: Unique token for idempotency (OSS models only).
168+
imported_model_kms_key_id: KMS key ID for encryption (OSS models only).
169169
deployment_name: Name for the deployment (Nova models only). If not provided,
170170
defaults to custom_model_name suffixed with '-deployment'.
171171
@@ -238,15 +238,23 @@ def deploy(
238238
}
239239
params = {k: v for k, v in params.items() if v is not None}
240240

241-
logger.info("Creating model import job for non-Nova deployment")
241+
logger.info("Creating model import job for OSS model deployment")
242242
print(f"[BedrockModelBuilder] Resolved S3 artifacts path: {self.s3_model_artifacts}")
243243
print(f"[BedrockModelBuilder] create_model_import_job params: {params}")
244-
response = self._get_bedrock_client().create_model_import_job(**params)
244+
import_response = self._get_bedrock_client().create_model_import_job(**params)
245245
logger.warning(
246-
"Bedrock create_model_import_job request: %s, response: %s", params, response
246+
"Bedrock create_model_import_job request: %s, response: %s", params, import_response
247247
)
248-
_log_bedrock_api_call("create_model_import_job", params, response)
249-
return response
248+
_log_bedrock_api_call("create_model_import_job", params, import_response)
249+
250+
job_arn = import_response.get("jobArn")
251+
self._wait_for_import_job_complete(job_arn)
252+
253+
# Return the completed job details
254+
job_details = self._get_bedrock_client().get_model_import_job(
255+
jobIdentifier=job_arn
256+
)
257+
return job_details
250258

251259
def create_deployment(
252260
self,
@@ -303,6 +311,140 @@ def create_deployment(
303311

304312
return response
305313

314+
def create_provisioned_throughput(
315+
self,
316+
model_id: str,
317+
provisioned_model_name: str,
318+
model_units: int = 1,
319+
commitment_duration: Optional[str] = None,
320+
tags: Optional[list] = None,
321+
poll_interval: int = 60,
322+
max_wait: int = 3600,
323+
) -> Dict[str, Any]:
324+
"""Create provisioned throughput for an imported model on Bedrock.
325+
326+
Calls CreateProvisionedModelThroughput and polls until the provisioned
327+
throughput reaches InService status.
328+
329+
Args:
330+
model_id: ARN or ID of the imported model.
331+
provisioned_model_name: Name for the provisioned throughput resource.
332+
model_units: Number of model units to provision. Defaults to 1.
333+
commitment_duration: Commitment duration. Valid values: 'OneMonth',
334+
'SixMonths'. If not provided, no commitment is set (on-demand).
335+
tags: Tags for the provisioned throughput resource.
336+
poll_interval: Seconds between status checks. Defaults to 60.
337+
max_wait: Maximum seconds to wait. Defaults to 3600.
338+
339+
Returns:
340+
Response from Bedrock create_provisioned_model_throughput API.
341+
342+
Raises:
343+
RuntimeError: If the provisioned throughput fails or times out.
344+
ValueError: If model_id or provisioned_model_name is not provided.
345+
"""
346+
if not model_id:
347+
raise ValueError("model_id is required for create_provisioned_throughput.")
348+
if not provisioned_model_name:
349+
raise ValueError(
350+
"provisioned_model_name is required for create_provisioned_throughput."
351+
)
352+
353+
params = {
354+
"modelId": model_id,
355+
"provisionedModelName": provisioned_model_name,
356+
"modelUnits": model_units,
357+
}
358+
if commitment_duration:
359+
params["commitmentDuration"] = commitment_duration
360+
if tags:
361+
params["tags"] = tags
362+
363+
logger.info(
364+
"Creating provisioned throughput '%s' for model %s with %d model units",
365+
provisioned_model_name,
366+
model_id,
367+
model_units,
368+
)
369+
response = self._get_bedrock_client().create_provisioned_model_throughput(**params)
370+
371+
provisioned_model_arn = response.get("provisionedModelArn")
372+
if provisioned_model_arn:
373+
self._wait_for_provisioned_throughput_in_service(
374+
provisioned_model_arn, poll_interval=poll_interval, max_wait=max_wait
375+
)
376+
377+
return response
378+
379+
def _wait_for_import_job_complete(
380+
self, job_arn: str, poll_interval: int = 60, max_wait: int = 3600
381+
):
382+
"""Poll Bedrock until the model import job reaches Completed status.
383+
384+
Args:
385+
job_arn: ARN of the model import job.
386+
poll_interval: Seconds between status checks. Defaults to 60.
387+
max_wait: Maximum seconds to wait. Defaults to 3600.
388+
389+
Raises:
390+
RuntimeError: If the import job fails or times out.
391+
"""
392+
elapsed = 0
393+
status = None
394+
while elapsed < max_wait:
395+
resp = self._get_bedrock_client().get_model_import_job(jobIdentifier=job_arn)
396+
status = resp.get("status")
397+
logger.info("Import job status: %s (elapsed %ds)", status, elapsed)
398+
if status == "Completed":
399+
return
400+
if status == "Failed":
401+
failure_reason = resp.get("failureMessage", "Unknown")
402+
raise RuntimeError(
403+
f"Model import job {job_arn} failed. Reason: {failure_reason}"
404+
)
405+
time.sleep(poll_interval)
406+
elapsed += poll_interval
407+
raise RuntimeError(
408+
f"Timed out after {max_wait}s waiting for import job {job_arn} to complete. "
409+
f"Last status: {status}"
410+
)
411+
412+
def _wait_for_provisioned_throughput_in_service(
413+
self, provisioned_model_arn: str, poll_interval: int = 60, max_wait: int = 3600
414+
):
415+
"""Poll Bedrock until provisioned throughput reaches InService status.
416+
417+
Args:
418+
provisioned_model_arn: ARN of the provisioned model throughput.
419+
poll_interval: Seconds between status checks. Defaults to 60.
420+
max_wait: Maximum seconds to wait. Defaults to 3600.
421+
422+
Raises:
423+
RuntimeError: If the provisioned throughput fails or times out.
424+
"""
425+
elapsed = 0
426+
status = None
427+
while elapsed < max_wait:
428+
resp = self._get_bedrock_client().get_provisioned_model_throughput(
429+
provisionedModelId=provisioned_model_arn
430+
)
431+
status = resp.get("status")
432+
logger.info("Provisioned throughput status: %s (elapsed %ds)", status, elapsed)
433+
if status == "InService":
434+
return
435+
if status == "Failed":
436+
failure_reason = resp.get("failureMessage", "Unknown")
437+
raise RuntimeError(
438+
f"Provisioned throughput {provisioned_model_arn} failed. "
439+
f"Reason: {failure_reason}"
440+
)
441+
time.sleep(poll_interval)
442+
elapsed += poll_interval
443+
raise RuntimeError(
444+
f"Timed out after {max_wait}s waiting for provisioned throughput "
445+
f"{provisioned_model_arn} to become InService. Last status: {status}"
446+
)
447+
306448
def _wait_for_model_active(
307449
self, model_arn: str, poll_interval: int = 60, max_wait: int = 3600
308450
):

0 commit comments

Comments
 (0)