diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/CHANGELOG.md b/sdk/contentunderstanding/azure-ai-contentunderstanding/CHANGELOG.md index 948c75550a64..c1ca68646dc0 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/CHANGELOG.md +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/CHANGELOG.md @@ -1,5 +1,10 @@ # Release History +## 1.0.2 (2026-04-20) + +### Bugs Fixed +- Exposed `usage` property on `AnalyzeLROPoller` and `AnalyzeAsyncLROPoller` to surface billing and token consumption details (`UsageDetails`) returned by the REST API. Previously the `usage` field in the LRO response envelope was discarded by the generated deserialization callback. + ## 1.0.1 (2026-03-06) ### Bugs Fixed diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/README.md b/sdk/contentunderstanding/azure-ai-contentunderstanding/README.md index fd8d79ffbbaa..9cb82881a725 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/README.md +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/README.md @@ -31,6 +31,7 @@ This table shows the relationship between SDK versions and supported API service | SDK version | Supported API service version | | ----------- | ----------------------------- | +| 1.0.2 | 2025-11-01 | | 1.0.1 | 2025-11-01 | | 1.0.0 | 2025-11-01 | diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/_version.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/_version.py index 83602e6274bc..8e44259d1a18 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/_version.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/_version.py @@ -6,4 +6,4 @@ # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- -VERSION = "1.0.1" +VERSION = "1.0.2" diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/aio/models/_patch.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/aio/models/_patch.py index eca66620c5d4..df2ac0fdbe6b 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/aio/models/_patch.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/aio/models/_patch.py @@ -10,8 +10,10 @@ """ import re -from typing import Any, TypeVar +from typing import Any, Optional, TypeVar from azure.core.polling import AsyncLROPoller, AsyncPollingMethod +from ... import models as _models +from ..._utils.model_base import _deserialize PollingReturnType_co = TypeVar("PollingReturnType_co", covariant=True) @@ -62,6 +64,26 @@ def operation_id(self) -> str: except (KeyError, ValueError) as e: raise ValueError(f"Could not extract operation ID: {str(e)}") from e + @property + def usage(self) -> Optional["_models.UsageDetails"]: + """Returns the usage details from the completed analyze operation. + + This property is available after the operation has completed successfully. + It provides information about the resources consumed during analysis, + including document pages, contextualization tokens, and LLM token breakdown. + + :return: The usage details, or None if the operation is not yet complete + or usage information is not available. + :rtype: ~azure.ai.contentunderstanding.models.UsageDetails or None + """ + if not self.done(): + return None + response = self.polling_method()._pipeline_response.http_response # type: ignore # pylint: disable=protected-access + usage_data = response.json().get("usage") + if usage_data is None: + return None + return _deserialize(_models.UsageDetails, usage_data) + @classmethod def from_poller(cls, poller: AsyncLROPoller[PollingReturnType_co]) -> "AnalyzeAsyncLROPoller[PollingReturnType_co]": # pyright: ignore[reportInvalidTypeArguments] # fmt: skip """Wrap an existing AsyncLROPoller without re-initializing the polling method. diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_patch.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_patch.py index 9dee69387cba..199ca653e0c7 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_patch.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_patch.py @@ -14,6 +14,8 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeVar from azure.core import CaseInsensitiveEnumMeta from azure.core.polling import LROPoller, PollingMethod +from . import _models +from .._utils.model_base import _deserialize from ._models import ( StringField, IntegerField, @@ -155,6 +157,26 @@ def operation_id(self) -> str: except (KeyError, ValueError) as e: raise ValueError(f"Could not extract operation ID: {str(e)}") from e + @property + def usage(self) -> Optional["_models.UsageDetails"]: + """Returns the usage details from the completed analyze operation. + + This property is available after the operation has completed successfully. + It provides information about the resources consumed during analysis, + including document pages, contextualization tokens, and LLM token breakdown. + + :return: The usage details, or None if the operation is not yet complete + or usage information is not available. + :rtype: ~azure.ai.contentunderstanding.models.UsageDetails or None + """ + if not self.done(): + return None + response = self.polling_method()._pipeline_response.http_response # type: ignore # pylint: disable=protected-access + usage_data = response.json().get("usage") + if usage_data is None: + return None + return _deserialize(_models.UsageDetails, usage_data) + @classmethod def from_poller(cls, poller: LROPoller[PollingReturnType_co]) -> "AnalyzeLROPoller[PollingReturnType_co]": # pyright: ignore[reportInvalidTypeArguments] # fmt: skip """Wrap an existing LROPoller without re-initializing the polling method. diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/README.md b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/README.md index d96bc5f0cfc2..70ee18191d3f 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/README.md +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/README.md @@ -63,6 +63,7 @@ Extracts structured fields from invoices using `prebuilt-invoice` analyzer. Show - Extracting structured fields (customer name, totals, dates, line items) - Working with field confidence scores and source locations - Accessing object fields and array fields +- Accessing usage details (billing metrics, token consumption per model) - Financial document processing (invoices, receipts, credit cards, bank statements, checks) ### Sample 04: Create Analyzer diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_binary_async.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_binary_async.py index 693c28b719d4..f3c9c9c62403 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_binary_async.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_binary_async.py @@ -107,7 +107,7 @@ async def main() -> None: if isinstance(range_result.contents[0], DocumentContent): range_doc = range_result.contents[0] print( - f"Content range analysis returned pages" + "Content range analysis returned pages" f" {range_doc.start_page_number} - {range_doc.end_page_number}" ) # [END analyze_binary_with_content_range] @@ -125,7 +125,7 @@ async def main() -> None: if isinstance(combine_range_result.contents[0], DocumentContent): combine_doc = combine_range_result.contents[0] print( - f"Combined content range analysis returned pages" + "Combined content range analysis returned pages" f" {combine_doc.start_page_number} - {combine_doc.end_page_number}" ) # [END analyze_binary_with_combined_content_range] diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_invoice_async.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_invoice_async.py index 6cbea029dc7d..62da86d9955c 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_invoice_async.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_invoice_async.py @@ -211,6 +211,37 @@ async def main() -> None: ) # [END extract_invoice_fields] + # [START get_usage] + # Access usage details from the poller (available after result() completes). + # Usage reports resource consumption for billing estimation: + # + # - document_pages_standard/basic/minimal: Pages processed at each extraction tier. + # Standard = layout + OCR (scanned docs), Basic = OCR only, Minimal = digital formats + # (DOCX, XLSX, HTML, TXT) that need no OCR. Charged per 1,000 pages. + # + # - contextualization_tokens: Fixed-rate tokens charged by Content Understanding for + # preparing context, generating confidence scores, source grounding, and formatting + # output. Typically 1,000 tokens per page. Charged separately from LLM tokens. + # + # - tokens: Dict of "{model}-input" / "{model}-output" token counts consumed by your + # Foundry model deployment (e.g. "gpt-4.1-input", "gpt-4.1-output"). These are + # billed on your Foundry deployment, not on Content Understanding. + # + # For full pricing details, see: + # https://learn.microsoft.com/azure/ai-services/content-understanding/pricing-explainer + usage = poller.usage + if usage: + print("\nUsage Details:") + if usage.document_pages_standard is not None: + print(f" Document pages (standard): {usage.document_pages_standard}") + if usage.contextualization_tokens is not None: + print(f" Contextualization tokens: {usage.contextualization_tokens}") + if usage.tokens: + print(" Model tokens:") + for model, count in usage.tokens.items(): + print(f" {model}: {count}") + # [END get_usage] + if not isinstance(credential, AzureKeyCredential): await credential.close() diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_url_async.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_url_async.py index 04562c733718..e4cca23f5474 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_url_async.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_url_async.py @@ -74,7 +74,7 @@ async def main() -> None: # You can replace this URL with your own publicly accessible document URL. document_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/mixed_financial_docs.pdf" - print(f"Analyzing document from URL with prebuilt-documentSearch...") + print("Analyzing document from URL with prebuilt-documentSearch...") print(f" URL: {document_url}") poller = await client.begin_analyze( @@ -119,7 +119,7 @@ async def main() -> None: range_doc_content = cast(DocumentContent, range_result.contents[0]) print( - f"Content range analysis returned pages" + "Content range analysis returned pages" f" {range_doc_content.start_page_number} - {range_doc_content.end_page_number}" ) @@ -133,7 +133,7 @@ async def main() -> None: combine_doc_content = cast(DocumentContent, combine_range_result.contents[0]) print( - f"Combined content range analysis returned pages" + "Combined content range analysis returned pages" f" {combine_doc_content.start_page_number} - {combine_doc_content.end_page_number}" ) # [END analyze_document_url_with_content_range] @@ -144,7 +144,7 @@ async def main() -> None: print("=" * 60) video_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/videos/sdk_samples/FlightSimulator.mp4" - print(f"Analyzing video from URL with prebuilt-videoSearch...") + print("Analyzing video from URL with prebuilt-videoSearch...") print(f" URL: {video_url}") poller = await client.begin_analyze( @@ -197,7 +197,7 @@ async def main() -> None: for range_media in video_range_result.contents: range_video_content = cast(AudioVisualContent, range_media) print( - f"Content range segment:" + "Content range segment:" f" {range_video_content.start_time_ms} ms - {range_video_content.end_time_ms} ms" ) # [END analyze_video_url_with_content_range] @@ -220,7 +220,7 @@ async def main() -> None: for from_media in video_from_result.contents: from_video = cast(AudioVisualContent, from_media) print( - f"'10000-' segment:" + "'10000-' segment:" f" {from_video.start_time_ms} ms - {from_video.end_time_ms} ms" ) @@ -239,7 +239,7 @@ async def main() -> None: for subsec_media in video_subsec_result.contents: subsec_video = cast(AudioVisualContent, subsec_media) print( - f"'1200-3651' segment:" + "'1200-3651' segment:" f" {subsec_video.start_time_ms} ms - {subsec_video.end_time_ms} ms" ) @@ -258,7 +258,7 @@ async def main() -> None: for combine_media in video_combine_result.contents: combine_video = cast(AudioVisualContent, combine_media) print( - f"'0-3000,30000-' segment:" + "'0-3000,30000-' segment:" f" {combine_video.start_time_ms} ms - {combine_video.end_time_ms} ms" ) # [END analyze_video_url_with_additional_content_ranges] @@ -269,7 +269,7 @@ async def main() -> None: print("=" * 60) audio_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/audio/callCenterRecording.mp3" - print(f"Analyzing audio from URL with prebuilt-audioSearch...") + print("Analyzing audio from URL with prebuilt-audioSearch...") print(f" URL: {audio_url}") poller = await client.begin_analyze( @@ -316,7 +316,7 @@ async def main() -> None: range_audio_content = cast(AudioVisualContent, audio_range_result.contents[0]) print( - f"Content range audio segment:" + "Content range audio segment:" f" {range_audio_content.start_time_ms} ms - {range_audio_content.end_time_ms} ms" ) # [END analyze_audio_url_with_content_range] @@ -338,7 +338,7 @@ async def main() -> None: audio_from_result = await audio_from_poller.result() audio_from_content = cast(AudioVisualContent, audio_from_result.contents[0]) print( - f"'10000-':" + "'10000-':" f" {audio_from_content.start_time_ms} ms - {audio_from_content.end_time_ms} ms" ) @@ -356,7 +356,7 @@ async def main() -> None: audio_subsec_result = await audio_subsec_poller.result() audio_subsec_content = cast(AudioVisualContent, audio_subsec_result.contents[0]) print( - f"'1200-3651':" + "'1200-3651':" f" {audio_subsec_content.start_time_ms} ms - {audio_subsec_content.end_time_ms} ms" ) @@ -374,7 +374,7 @@ async def main() -> None: audio_combine_result = await audio_combine_poller.result() audio_combine_content = cast(AudioVisualContent, audio_combine_result.contents[0]) print( - f"'0-3000,30000-':" + "'0-3000,30000-':" f" {audio_combine_content.start_time_ms} ms - {audio_combine_content.end_time_ms} ms" ) # [END analyze_audio_url_with_additional_content_ranges] @@ -385,7 +385,7 @@ async def main() -> None: print("=" * 60) image_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/image/pieChart.jpg" - print(f"Analyzing image from URL with prebuilt-imageSearch...") + print("Analyzing image from URL with prebuilt-imageSearch...") print(f" URL: {image_url}") poller = await client.begin_analyze( diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_copy_analyzer_async.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_copy_analyzer_async.py index 3878d7e67eff..80a30a65b565 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_copy_analyzer_async.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_copy_analyzer_async.py @@ -119,7 +119,7 @@ async def main() -> None: ) await poller.result() - print(f"Analyzer copied successfully!") + print("Analyzer copied successfully!") # [END copy_analyzer] # [START update_and_verify_analyzer] @@ -133,7 +133,7 @@ async def main() -> None: tags={"modelType": "model_in_production"}, ) - print(f"Updating target analyzer with production tag...") + print("Updating target analyzer with production tag...") await client.update_analyzer( analyzer_id=target_analyzer_id, resource=updated_analyzer ) @@ -148,7 +148,7 @@ async def main() -> None: # [END update_and_verify_analyzer] # [START delete_copied_analyzers] - print(f"\nCleaning up analyzers...") + print("\nCleaning up analyzers...") try: await client.delete_analyzer(analyzer_id=source_analyzer_id) diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_grant_copy_auth_async.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_grant_copy_auth_async.py index 59e83da8c893..d9f2fa896490 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_grant_copy_auth_async.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_grant_copy_auth_async.py @@ -75,8 +75,8 @@ Example resource ID format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/{name} - Important: Cross-resource copying requires credential-based authentication (such as DefaultAzureCredential). - API keys cannot be used for cross-resource operations. + Note: If API keys are not provided, DefaultAzureCredential will be used. + Cross-resource copying with DefaultAzureCredential requires 'Cognitive Services User' role on both source and target resources. """ import asyncio @@ -208,7 +208,7 @@ async def main() -> None: resource=source_analyzer, ) await poller.result() - print(f" Source analyzer created successfully!") + print(" Source analyzer created successfully!") # Step 2: Grant copy authorization # Authorization must be granted by the source resource before the target resource can copy @@ -216,7 +216,7 @@ async def main() -> None: # - The source analyzer ID to be copied # - The target Azure resource ID that is allowed to receive the copy # - The target region where the copy will be performed (optional, defaults to current region) - print(f"\nStep 2: Granting copy authorization from source resource...") + print("\nStep 2: Granting copy authorization from source resource...") print(f" Target Azure Resource ID: {target_resource_id}") print(f" Target Region: {target_region}") @@ -226,7 +226,7 @@ async def main() -> None: target_region=target_region, ) - print(f" Authorization granted successfully!") + print(" Authorization granted successfully!") print(f" Target Azure Resource ID: {copy_auth.target_azure_resource_id}") print(f" Target Region: {target_region}") print(f" Expires at: {copy_auth.expires_at}") @@ -235,7 +235,7 @@ async def main() -> None: # The copy_analyzer method must be called on the target client because the target # resource is the one receiving and creating the copy. The target resource validates # that authorization was previously granted by the source resource. - print(f"\nStep 3: Copying analyzer from source to target...") + print("\nStep 3: Copying analyzer from source to target...") print(f" Source Analyzer ID: {source_analyzer_id}") print(f" Source Azure Resource ID: {source_resource_id}") print(f" Source Region: {source_region}") @@ -248,22 +248,22 @@ async def main() -> None: source_region=source_region, ) await copy_poller.result() - print(f" Analyzer copied successfully to target resource!") + print(" Analyzer copied successfully to target resource!") # Step 4: Verify the copy # Retrieve the analyzer from the target resource to verify the copy was successful - print(f"\nStep 4: Verifying the copied analyzer...") + print("\nStep 4: Verifying the copied analyzer...") copied_analyzer = await target_client.get_analyzer( analyzer_id=target_analyzer_id ) print(f" Target Analyzer ID: {copied_analyzer.analyzer_id}") print(f" Description: {copied_analyzer.description}") print(f" Status: {copied_analyzer.status}") - print(f"\nCross-resource copy completed successfully!") + print("\nCross-resource copy completed successfully!") finally: # Clean up - create new client instances for cleanup since the original ones are closed - print(f"\nCleaning up...") + print("\nCleaning up...") cleanup_source_client = ContentUnderstandingClient( endpoint=source_endpoint, credential=source_credential ) diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_invoice.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_invoice.py index 38542a8a3fa8..c7812c180597 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_invoice.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_invoice.py @@ -205,6 +205,37 @@ def main() -> None: ) # [END extract_invoice_fields] + # [START get_usage] + # Access usage details from the poller (available after result() completes). + # Usage reports resource consumption for billing estimation: + # + # - document_pages_standard/basic/minimal: Pages processed at each extraction tier. + # Standard = layout + OCR (scanned docs), Basic = OCR only, Minimal = digital formats + # (DOCX, XLSX, HTML, TXT) that need no OCR. Charged per 1,000 pages. + # + # - contextualization_tokens: Fixed-rate tokens charged by Content Understanding for + # preparing context, generating confidence scores, source grounding, and formatting + # output. Typically 1,000 tokens per page. Charged separately from LLM tokens. + # + # - tokens: Dict of "{model}-input" / "{model}-output" token counts consumed by your + # Foundry model deployment (e.g. "gpt-4.1-input", "gpt-4.1-output"). These are + # billed on your Foundry deployment, not on Content Understanding. + # + # For full pricing details, see: + # https://learn.microsoft.com/azure/ai-services/content-understanding/pricing-explainer + usage = poller.usage + if usage: + print("\nUsage Details:") + if usage.document_pages_standard is not None: + print(f" Document pages (standard): {usage.document_pages_standard}") + if usage.contextualization_tokens is not None: + print(f" Contextualization tokens: {usage.contextualization_tokens}") + if usage.tokens: + print(" Model tokens:") + for model, count in usage.tokens.items(): + print(f" {model}: {count}") + # [END get_usage] + if __name__ == "__main__": main() diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_url.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_url.py index 1be6e13c0073..0c3e9570d098 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_url.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_url.py @@ -72,7 +72,7 @@ def main() -> None: # You can replace this URL with your own publicly accessible document URL. document_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/mixed_financial_docs.pdf" - print(f"Analyzing document from URL with prebuilt-documentSearch...") + print("Analyzing document from URL with prebuilt-documentSearch...") print(f" URL: {document_url}") poller = client.begin_analyze( @@ -117,7 +117,7 @@ def main() -> None: range_doc_content = cast(DocumentContent, range_result.contents[0]) print( - f"Content range analysis returned pages" + "Content range analysis returned pages" f" {range_doc_content.start_page_number} - {range_doc_content.end_page_number}" ) @@ -131,7 +131,7 @@ def main() -> None: combine_doc_content = cast(DocumentContent, combine_range_result.contents[0]) print( - f"Combined content range analysis returned pages" + "Combined content range analysis returned pages" f" {combine_doc_content.start_page_number} - {combine_doc_content.end_page_number}" ) # [END analyze_document_url_with_content_range] @@ -142,7 +142,7 @@ def main() -> None: print("=" * 60) video_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/videos/sdk_samples/FlightSimulator.mp4" - print(f"Analyzing video from URL with prebuilt-videoSearch...") + print("Analyzing video from URL with prebuilt-videoSearch...") print(f" URL: {video_url}") poller = client.begin_analyze( @@ -193,7 +193,7 @@ def main() -> None: for range_media in video_range_result.contents: range_video_content = cast(AudioVisualContent, range_media) print( - f"Content range segment:" + "Content range segment:" f" {range_video_content.start_time_ms} ms - {range_video_content.end_time_ms} ms" ) # [END analyze_video_url_with_content_range] @@ -216,7 +216,7 @@ def main() -> None: for from_media in video_from_result.contents: from_video = cast(AudioVisualContent, from_media) print( - f"'10000-' segment:" + "'10000-' segment:" f" {from_video.start_time_ms} ms - {from_video.end_time_ms} ms" ) @@ -235,7 +235,7 @@ def main() -> None: for subsec_media in video_subsec_result.contents: subsec_video = cast(AudioVisualContent, subsec_media) print( - f"'1200-3651' segment:" + "'1200-3651' segment:" f" {subsec_video.start_time_ms} ms - {subsec_video.end_time_ms} ms" ) @@ -254,7 +254,7 @@ def main() -> None: for combine_media in video_combine_result.contents: combine_video = cast(AudioVisualContent, combine_media) print( - f"'0-3000,30000-' segment:" + "'0-3000,30000-' segment:" f" {combine_video.start_time_ms} ms - {combine_video.end_time_ms} ms" ) # [END analyze_video_url_with_additional_content_ranges] @@ -265,7 +265,7 @@ def main() -> None: print("=" * 60) audio_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/audio/callCenterRecording.mp3" - print(f"Analyzing audio from URL with prebuilt-audioSearch...") + print("Analyzing audio from URL with prebuilt-audioSearch...") print(f" URL: {audio_url}") poller = client.begin_analyze( @@ -309,7 +309,7 @@ def main() -> None: range_audio_content = cast(AudioVisualContent, audio_range_result.contents[0]) print( - f"Content range audio segment:" + "Content range audio segment:" f" {range_audio_content.start_time_ms} ms - {range_audio_content.end_time_ms} ms" ) # [END analyze_audio_url_with_content_range] @@ -331,7 +331,7 @@ def main() -> None: audio_from_result = audio_from_poller.result() audio_from_content = cast(AudioVisualContent, audio_from_result.contents[0]) print( - f"'10000-':" + "'10000-':" f" {audio_from_content.start_time_ms} ms - {audio_from_content.end_time_ms} ms" ) @@ -349,7 +349,7 @@ def main() -> None: audio_subsec_result = audio_subsec_poller.result() audio_subsec_content = cast(AudioVisualContent, audio_subsec_result.contents[0]) print( - f"'1200-3651':" + "'1200-3651':" f" {audio_subsec_content.start_time_ms} ms - {audio_subsec_content.end_time_ms} ms" ) @@ -367,7 +367,7 @@ def main() -> None: audio_combine_result = audio_combine_poller.result() audio_combine_content = cast(AudioVisualContent, audio_combine_result.contents[0]) print( - f"'0-3000,30000-':" + "'0-3000,30000-':" f" {audio_combine_content.start_time_ms} ms - {audio_combine_content.end_time_ms} ms" ) # [END analyze_audio_url_with_additional_content_ranges] @@ -378,7 +378,7 @@ def main() -> None: print("=" * 60) image_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/image/pieChart.jpg" - print(f"Analyzing image from URL with prebuilt-imageSearch...") + print("Analyzing image from URL with prebuilt-imageSearch...") print(f" URL: {image_url}") poller = client.begin_analyze( diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_copy_analyzer.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_copy_analyzer.py index ceae81e28485..c023cb6c874f 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_copy_analyzer.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_copy_analyzer.py @@ -116,7 +116,7 @@ def main() -> None: ) poller.result() - print(f"Analyzer copied successfully!") + print("Analyzer copied successfully!") # [END copy_analyzer] # [START update_and_verify_analyzer] @@ -130,7 +130,7 @@ def main() -> None: tags={"modelType": "model_in_production"}, ) - print(f"Updating target analyzer with production tag...") + print("Updating target analyzer with production tag...") client.update_analyzer(analyzer_id=target_analyzer_id, resource=updated_analyzer) # Verify the update @@ -143,7 +143,7 @@ def main() -> None: # [END update_and_verify_analyzer] # [START delete_copied_analyzers] - print(f"\nCleaning up analyzers...") + print("\nCleaning up analyzers...") try: client.delete_analyzer(analyzer_id=source_analyzer_id) diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_grant_copy_auth.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_grant_copy_auth.py index 17cd03af239c..b4ef028c34c4 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_grant_copy_auth.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_grant_copy_auth.py @@ -75,8 +75,8 @@ Example resource ID format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/{name} - Important: Cross-resource copying requires credential-based authentication (such as DefaultAzureCredential). - API keys cannot be used for cross-resource operations. + Note: If API keys are not provided, DefaultAzureCredential will be used. + Cross-resource copying with DefaultAzureCredential requires 'Cognitive Services User' role on both source and target resources. """ import os @@ -206,7 +206,7 @@ def main() -> None: resource=source_analyzer, ) poller.result() - print(f" Source analyzer created successfully!") + print(" Source analyzer created successfully!") # Step 2: Grant copy authorization # Authorization must be granted by the source resource before the target resource can copy @@ -214,7 +214,7 @@ def main() -> None: # - The source analyzer ID to be copied # - The target Azure resource ID that is allowed to receive the copy # - The target region where the copy will be performed (optional, defaults to current region) - print(f"\nStep 2: Granting copy authorization from source resource...") + print("\nStep 2: Granting copy authorization from source resource...") print(f" Target Azure Resource ID: {target_resource_id}") print(f" Target Region: {target_region}") @@ -224,7 +224,7 @@ def main() -> None: target_region=target_region, ) - print(f" Authorization granted successfully!") + print(" Authorization granted successfully!") print(f" Target Azure Resource ID: {copy_auth.target_azure_resource_id}") print(f" Target Region: {target_region}") print(f" Expires at: {copy_auth.expires_at}") @@ -233,7 +233,7 @@ def main() -> None: # The copy_analyzer method must be called on the target client because the target # resource is the one receiving and creating the copy. The target resource validates # that authorization was previously granted by the source resource. - print(f"\nStep 3: Copying analyzer from source to target...") + print("\nStep 3: Copying analyzer from source to target...") print(f" Source Analyzer ID: {source_analyzer_id}") print(f" Source Azure Resource ID: {source_resource_id}") print(f" Source Region: {source_region}") @@ -246,20 +246,20 @@ def main() -> None: source_region=source_region, ) copy_poller.result() - print(f" Analyzer copied successfully to target resource!") + print(" Analyzer copied successfully to target resource!") # Step 4: Verify the copy # Retrieve the analyzer from the target resource to verify the copy was successful - print(f"\nStep 4: Verifying the copied analyzer...") + print("\nStep 4: Verifying the copied analyzer...") copied_analyzer = target_client.get_analyzer(analyzer_id=target_analyzer_id) print(f" Target Analyzer ID: {copied_analyzer.analyzer_id}") print(f" Description: {copied_analyzer.description}") print(f" Status: {copied_analyzer.status}") - print(f"\nCross-resource copy completed successfully!") + print("\nCross-resource copy completed successfully!") finally: # Clean up: Delete both source and target analyzers - print(f"\nCleaning up...") + print("\nCleaning up...") try: source_client.delete_analyzer(analyzer_id=source_analyzer_id) print(f" Source analyzer '{source_analyzer_id}' deleted.") diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_invoice.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_invoice.py index 865815bd13cf..97015d1e95cc 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_invoice.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_invoice.py @@ -215,4 +215,26 @@ def test_sample_analyze_invoice(self, contentunderstanding_endpoint: str, **kwar else: print("[INFO] LineItems field not found in this document") + # Verify usage details (available after result() completes) + usage = poller.usage + assert usage is not None, "Usage details should be available after analysis" + print("[PASS] Usage details available") + + # Verify at least one page metric is set + has_pages = ( + (usage.document_pages_standard is not None and usage.document_pages_standard > 0) + or (usage.document_pages_basic is not None and usage.document_pages_basic > 0) + or (usage.document_pages_minimal is not None and usage.document_pages_minimal > 0) + ) + assert has_pages, "Usage should report at least one document page metric" + print(f"[PASS] Usage reports document pages (standard={usage.document_pages_standard}, basic={usage.document_pages_basic}, minimal={usage.document_pages_minimal})") + + # Verify token usage is reported + if usage.tokens: + assert len(usage.tokens) > 0, "Token usage should have at least one model entry" + for model, count in usage.tokens.items(): + assert count > 0, f"Token count for {model} should be positive" + print(f"[INFO] Token usage: {model} = {count}") + print(f"[PASS] Token usage reported for {len(usage.tokens)} model(s)") + print("\n[SUCCESS] All test_sample_analyze_invoice assertions passed") diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_invoice_async.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_invoice_async.py index 1015b54454a9..d041c091064a 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_invoice_async.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_invoice_async.py @@ -214,5 +214,27 @@ async def test_sample_analyze_invoice_async(self, contentunderstanding_endpoint: else: print("[INFO] LineItems field not found in this document") + # Verify usage details (available after result() completes) + usage = poller.usage + assert usage is not None, "Usage details should be available after analysis" + print("[PASS] Usage details available") + + # Verify at least one page metric is set + has_pages = ( + (usage.document_pages_standard is not None and usage.document_pages_standard > 0) + or (usage.document_pages_basic is not None and usage.document_pages_basic > 0) + or (usage.document_pages_minimal is not None and usage.document_pages_minimal > 0) + ) + assert has_pages, "Usage should report at least one document page metric" + print(f"[PASS] Usage reports document pages (standard={usage.document_pages_standard}, basic={usage.document_pages_basic}, minimal={usage.document_pages_minimal})") + + # Verify token usage is reported + if usage.tokens: + assert len(usage.tokens) > 0, "Token usage should have at least one model entry" + for model, count in usage.tokens.items(): + assert count > 0, f"Token count for {model} should be positive" + print(f"[INFO] Token usage: {model} = {count}") + print(f"[PASS] Token usage reported for {len(usage.tokens)} model(s)") + await client.close() print("\n[SUCCESS] All test_sample_analyze_invoice_async assertions passed") diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_analyzer_operation_id.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_analyzer_operation_id.py index d3647016a6ae..c1e0a377b9d1 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_analyzer_operation_id.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_analyzer_operation_id.py @@ -16,7 +16,7 @@ AnalyzeLROPoller, _parse_operation_id, ) -from azure.ai.contentunderstanding.models import AnalysisInput +from azure.ai.contentunderstanding.models import AnalysisInput, UsageDetails from azure.ai.contentunderstanding import ContentUnderstandingClient @@ -164,3 +164,115 @@ def test_analyze_operation_returns_custom_poller(self): assert hasattr(result, "operation_id") operation_id = result.operation_id assert operation_id == "test-op-id-123" + + +class TestAnalyzeLROPollerUsage: + """Test the usage property on AnalyzeLROPoller (GitHub issue #46249).""" + + def _make_poller_with_usage(self, usage_json): + """Helper to create an AnalyzeLROPoller with a mocked final response containing usage data.""" + mock_polling_method = Mock() + mock_initial_response = Mock() + mock_http_response = Mock() + mock_http_response.headers = { + "Operation-Location": "https://endpoint/contentunderstanding/analyzerResults/test-op-id?api-version=2025-11-01" + } + mock_initial_response.http_response = mock_http_response + mock_polling_method.return_value = mock_polling_method + mock_polling_method._initial_response = mock_initial_response + + # Mock the final pipeline response (used by .usage property) + mock_final_response = Mock() + mock_final_http_response = Mock() + response_json = { + "id": "test-op-id", + "status": "Succeeded", + "result": {"contents": []}, + } + if usage_json is not None: + response_json["usage"] = usage_json + mock_final_http_response.json.return_value = response_json + mock_final_response.http_response = mock_final_http_response + mock_polling_method._pipeline_response = mock_final_response + + return AnalyzeLROPoller( + client=Mock(), + initial_response=Mock(), + deserialization_callback=Mock(), + polling_method=mock_polling_method, + ) + + def test_usage_with_full_data(self): + """Test usage property returns UsageDetails with all fields populated.""" + usage_json = { + "documentPagesMinimal": 0, + "documentPagesBasic": 0, + "documentPagesStandard": 2, + "audioHours": 0.0, + "videoHours": 0.0, + "contextualizationTokens": 1500, + "tokens": { + "gpt-4.1-input": 500, + "gpt-4.1-output": 200, + "text-embedding-3-large-input": 300, + }, + } + + poller = self._make_poller_with_usage(usage_json) + usage = poller.usage + + assert usage is not None + assert isinstance(usage, UsageDetails) + assert usage.document_pages_standard == 2 + assert usage.document_pages_minimal == 0 + assert usage.document_pages_basic == 0 + assert usage.contextualization_tokens == 1500 + assert usage.tokens == { + "gpt-4.1-input": 500, + "gpt-4.1-output": 200, + "text-embedding-3-large-input": 300, + } + + def test_usage_with_partial_data(self): + """Test usage property with only some fields populated.""" + usage_json = { + "documentPagesStandard": 1, + "contextualizationTokens": 100, + } + + poller = self._make_poller_with_usage(usage_json) + usage = poller.usage + + assert usage is not None + assert usage.document_pages_standard == 1 + assert usage.contextualization_tokens == 100 + assert usage.document_pages_minimal is None + assert usage.tokens is None + + def test_usage_returns_none_when_not_present(self): + """Test usage property returns None when usage is not in the response.""" + poller = self._make_poller_with_usage(None) + usage = poller.usage + + assert usage is None + + def test_usage_returns_none_before_completion(self): + """Test usage property returns None when the polling has not yet completed.""" + mock_polling_method = Mock() + + poller = AnalyzeLROPoller( + client=Mock(), + initial_response=Mock(), + deserialization_callback=Mock(), + polling_method=mock_polling_method, + ) + + # Simulate an in-progress operation: LROPoller.done() returns True when + # _thread is None, so set _thread to a mock with is_alive() returning True. + mock_thread = Mock() + mock_thread.is_alive.return_value = True + poller._thread = mock_thread + + assert not poller.done() + usage = poller.usage + assert usage is None diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_analyzer_operation_id_async.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_analyzer_operation_id_async.py new file mode 100644 index 000000000000..7766b1c4c0cc --- /dev/null +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_analyzer_operation_id_async.py @@ -0,0 +1,90 @@ +# pylint: disable=line-too-long,useless-suppression +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------- + +""" +Tests for Content Understanding async analyzer operation ID and usage functionality. +""" + +from unittest.mock import Mock +from azure.ai.contentunderstanding.aio.models._patch import AnalyzeAsyncLROPoller +from azure.ai.contentunderstanding.models import UsageDetails + + +class TestAnalyzeAsyncLROPollerUsage: + """Test the usage property on AnalyzeAsyncLROPoller (async counterpart).""" + + def _make_async_poller_with_usage(self, usage_json, done=True): + """Helper to create an AnalyzeAsyncLROPoller with a mocked final response.""" + mock_polling_method = Mock() + mock_initial_response = Mock() + mock_http_response = Mock() + mock_http_response.headers = { + "Operation-Location": "https://endpoint/contentunderstanding/analyzerResults/test-op-id?api-version=2025-11-01" + } + mock_initial_response.http_response = mock_http_response + mock_polling_method.return_value = mock_polling_method + mock_polling_method._initial_response = mock_initial_response + + mock_final_response = Mock() + mock_final_http_response = Mock() + response_json = { + "id": "test-op-id", + "status": "Succeeded", + "result": {"contents": []}, + } + if usage_json is not None: + response_json["usage"] = usage_json + mock_final_http_response.json.return_value = response_json + mock_final_response.http_response = mock_final_http_response + mock_polling_method._pipeline_response = mock_final_response + + poller = AnalyzeAsyncLROPoller( + client=Mock(), + initial_response=Mock(), + deserialization_callback=Mock(), + polling_method=mock_polling_method, + ) + # AsyncLROPoller.done() checks self._done (defaults to False after __init__) + poller._done = done + return poller + + def test_async_usage_with_full_data(self): + """Test async usage property returns UsageDetails with all fields populated.""" + usage_json = { + "documentPagesMinimal": 0, + "documentPagesBasic": 0, + "documentPagesStandard": 2, + "audioHours": 0.0, + "videoHours": 0.0, + "contextualizationTokens": 1500, + "tokens": { + "gpt-4.1-input": 500, + "gpt-4.1-output": 200, + }, + } + + poller = self._make_async_poller_with_usage(usage_json) + usage = poller.usage + + assert usage is not None + assert isinstance(usage, UsageDetails) + assert usage.document_pages_standard == 2 + assert usage.contextualization_tokens == 1500 + assert usage.tokens == {"gpt-4.1-input": 500, "gpt-4.1-output": 200} + + def test_async_usage_returns_none_when_not_present(self): + """Test async usage property returns None when usage is not in the response.""" + poller = self._make_async_poller_with_usage(None) + usage = poller.usage + assert usage is None + + def test_async_usage_returns_none_before_completion(self): + """Test async usage property returns None when the polling has not yet completed.""" + poller = self._make_async_poller_with_usage({"documentPagesStandard": 1}, done=False) + assert not poller.done() + usage = poller.usage + assert usage is None diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_understanding_content_analyzers_operations.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_understanding_content_analyzers_operations.py index a7592b044843..242843293ab0 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_understanding_content_analyzers_operations.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_understanding_content_analyzers_operations.py @@ -21,6 +21,7 @@ new_simple_content_analyzer_object, new_marketing_video_analyzer_object, assert_poller_properties, + assert_analyze_poller_usage, assert_simple_content_analyzer_result, save_analysis_result_to_file, save_keyframe_image_to_file, @@ -604,6 +605,7 @@ def test_content_analyzers_analyze_binary_extract_markdown(self, contentundersta # Wait for completion result = poller.result() assert_poller_properties(poller) + assert_analyze_poller_usage(poller) # Verify result assert result is not None, "Analysis result should not be null" @@ -739,6 +741,7 @@ def test_content_analyzers_analyze_configs(self, contentunderstanding_endpoint: # Wait for completion result = poller.result() assert_poller_properties(poller) + assert_analyze_poller_usage(poller) # Verify result assert result is not None, "Analysis result should not be null" @@ -801,6 +804,7 @@ def test_content_analyzers_analyze_return_raw_json(self, contentunderstanding_en # Wait for completion result = poller.result() assert_poller_properties(poller) + assert_analyze_poller_usage(poller) # Verify operation completed successfully assert result is not None, "Analysis result should not be null" diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_understanding_content_analyzers_operations_async.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_understanding_content_analyzers_operations_async.py index e2f42ae04a9c..d5b8450ed9ab 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_understanding_content_analyzers_operations_async.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_understanding_content_analyzers_operations_async.py @@ -21,6 +21,7 @@ new_invoice_analyzer_object, new_marketing_video_analyzer_object, assert_poller_properties, + assert_analyze_poller_usage, assert_simple_content_analyzer_result, assert_invoice_fields, assert_document_properties, @@ -978,6 +979,7 @@ async def test_analyze_binary_extract_markdown_async(self, contentunderstanding_ # Wait for completion result = await poller.result() assert_poller_properties(poller) + assert_analyze_poller_usage(poller) # Verify result assert result is not None, "Analysis result should not be null" @@ -1113,6 +1115,7 @@ async def test_analyze_configs_async(self, contentunderstanding_endpoint: str) - # Wait for completion result = await poller.result() assert_poller_properties(poller) + assert_analyze_poller_usage(poller) # Verify result assert result is not None, "Analysis result should not be null" @@ -1175,6 +1178,7 @@ async def test_analyze_return_raw_json_async(self, contentunderstanding_endpoint # Wait for completion result = await poller.result() assert_poller_properties(poller) + assert_analyze_poller_usage(poller) # Verify operation completed successfully assert result is not None, "Analysis result should not be null" diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_helpers.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_helpers.py index 5d405339cabf..57e1d071c2c4 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_helpers.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_helpers.py @@ -132,6 +132,25 @@ def assert_poller_properties(poller: Any, poller_name: str = "Poller") -> None: print(f"{poller_name} properties verified successfully") +def assert_analyze_poller_usage(poller: Any, poller_name: str = "Poller") -> None: + """Assert the usage property on a completed analyze poller (AnalyzeLROPoller / AnalyzeAsyncLROPoller). + + Must be called AFTER poller.result() has been called, since usage is only available + once the LRO has completed and the final polling response contains the usage data. + + Args: + poller: The AnalyzeLROPoller or AnalyzeAsyncLROPoller instance to validate + poller_name: Optional name for the poller in log messages + + Raises: + AssertionError: If the usage property is not available or invalid + """ + assert hasattr(poller, "usage"), f"{poller_name} should have a 'usage' property" + usage = poller.usage + assert usage is not None, f"{poller_name} usage should not be None after completion" + print(f"{poller_name} usage verified: {usage}") + + def assert_simple_content_analyzer_result(analysis_result: Any, result_name: str = "Analysis result") -> None: """Assert simple content analyzer result properties and field extraction.