@@ -2388,6 +2388,189 @@ def count_tokens(msg):
23882388 )
23892389
23902390
2391+ def test_completions_token_usage_choices_none ():
2392+ """When response.choices is None (e.g. OpenRouter upstream error), no crash and no output tokens."""
2393+ span = mock .MagicMock ()
2394+
2395+ def count_tokens (msg ):
2396+ return len (str (msg ))
2397+
2398+ response = mock .MagicMock ()
2399+ response .usage = mock .MagicMock ()
2400+ response .usage .completion_tokens = None
2401+ response .usage .prompt_tokens = None
2402+ response .usage .total_tokens = None
2403+ response .choices = None
2404+ messages = []
2405+ streaming_message_responses = None
2406+
2407+ with mock .patch (
2408+ "sentry_sdk.integrations.openai.record_token_usage"
2409+ ) as mock_record_token_usage :
2410+ _calculate_completions_token_usage (
2411+ messages = messages ,
2412+ response = response ,
2413+ span = span ,
2414+ streaming_message_responses = streaming_message_responses ,
2415+ streaming_message_total_token_usage = None ,
2416+ count_tokens = count_tokens ,
2417+ )
2418+ mock_record_token_usage .assert_called_once_with (
2419+ span ,
2420+ input_tokens = None ,
2421+ input_tokens_cached = None ,
2422+ output_tokens = None ,
2423+ output_tokens_reasoning = None ,
2424+ total_tokens = None ,
2425+ )
2426+
2427+
2428+ def test_nonstreaming_chat_completion_choices_none (sentry_init , capture_events ):
2429+ """When choices=None (e.g. OpenRouter upstream error), no crash and span is still created."""
2430+ sentry_init (integrations = [OpenAIIntegration ()], traces_sample_rate = 1.0 )
2431+ events = capture_events ()
2432+
2433+ response = mock .MagicMock ()
2434+ response .id = "chat-id"
2435+ response .model = "gpt-3.5-turbo"
2436+ response .choices = None
2437+ response .usage = mock .MagicMock ()
2438+ response .usage .completion_tokens = None
2439+ response .usage .prompt_tokens = None
2440+ response .usage .total_tokens = None
2441+
2442+ client = OpenAI (api_key = "z" )
2443+ client .chat .completions ._post = mock .Mock (return_value = response )
2444+
2445+ with start_transaction (name = "openai tx" ):
2446+ result = client .chat .completions .create (
2447+ model = "some-model" ,
2448+ messages = [{"role" : "user" , "content" : "hello" }],
2449+ )
2450+
2451+ assert result is response
2452+ (tx ,) = events
2453+ assert tx ["type" ] == "transaction"
2454+ assert len (tx ["spans" ]) == 1
2455+ span = tx ["spans" ][0 ]
2456+ assert span ["op" ] == "gen_ai.chat"
2457+
2458+
2459+ def test_streaming_chat_completion_choices_none (
2460+ sentry_init ,
2461+ capture_events ,
2462+ get_model_response ,
2463+ server_side_event_chunks ,
2464+ ):
2465+ """When a streaming chunk has choices=None, no crash and token usage is still recorded."""
2466+ sentry_init (
2467+ integrations = [OpenAIIntegration (include_prompts = False )],
2468+ traces_sample_rate = 1.0 ,
2469+ send_default_pii = False ,
2470+ )
2471+ events = capture_events ()
2472+
2473+ # Use model_construct to bypass Pydantic validation and set choices=None
2474+ chunk_with_none_choices = ChatCompletionChunk .model_construct (
2475+ id = "1" ,
2476+ choices = None ,
2477+ created = 100000 ,
2478+ model = "model-id" ,
2479+ object = "chat.completion.chunk" ,
2480+ usage = CompletionUsage (
2481+ prompt_tokens = 5 ,
2482+ completion_tokens = 0 ,
2483+ total_tokens = 5 ,
2484+ ),
2485+ )
2486+ client = OpenAI (api_key = "z" )
2487+ returned_stream = get_model_response (
2488+ server_side_event_chunks (
2489+ [chunk_with_none_choices ],
2490+ include_event_type = False ,
2491+ )
2492+ )
2493+
2494+ with mock .patch .object (
2495+ client .chat ._client ._client ,
2496+ "send" ,
2497+ return_value = returned_stream ,
2498+ ):
2499+ with start_transaction (name = "openai tx" ):
2500+ response_stream = client .chat .completions .create (
2501+ model = "some-model" ,
2502+ messages = [{"role" : "user" , "content" : "hello" }],
2503+ stream = True ,
2504+ )
2505+ for _ in response_stream :
2506+ pass
2507+
2508+ (tx ,) = events
2509+ assert tx ["type" ] == "transaction"
2510+ span = tx ["spans" ][0 ]
2511+ assert span ["op" ] == "gen_ai.chat"
2512+ assert span ["data" ][SPANDATA .GEN_AI_RESPONSE_STREAMING ] is True
2513+
2514+
2515+ @pytest .mark .asyncio
2516+ async def test_streaming_chat_completion_choices_none_async (
2517+ sentry_init ,
2518+ capture_events ,
2519+ get_model_response ,
2520+ async_iterator ,
2521+ server_side_event_chunks ,
2522+ ):
2523+ """When an async streaming chunk has choices=None, no crash and span is still created."""
2524+ sentry_init (
2525+ integrations = [OpenAIIntegration (include_prompts = False )],
2526+ traces_sample_rate = 1.0 ,
2527+ send_default_pii = False ,
2528+ )
2529+ events = capture_events ()
2530+
2531+ chunk_with_none_choices = ChatCompletionChunk .model_construct (
2532+ id = "1" ,
2533+ choices = None ,
2534+ created = 100000 ,
2535+ model = "model-id" ,
2536+ object = "chat.completion.chunk" ,
2537+ usage = CompletionUsage (
2538+ prompt_tokens = 5 ,
2539+ completion_tokens = 0 ,
2540+ total_tokens = 5 ,
2541+ ),
2542+ )
2543+ client = AsyncOpenAI (api_key = "z" )
2544+ returned_stream = get_model_response (
2545+ async_iterator (
2546+ server_side_event_chunks (
2547+ [chunk_with_none_choices ],
2548+ include_event_type = False ,
2549+ )
2550+ )
2551+ )
2552+
2553+ with mock .patch .object (
2554+ client .chat ._client ._client ,
2555+ "send" ,
2556+ return_value = returned_stream ,
2557+ ):
2558+ with start_transaction (name = "openai tx" ):
2559+ response_stream = await client .chat .completions .create (
2560+ model = "some-model" ,
2561+ messages = [{"role" : "user" , "content" : "hello" }],
2562+ stream = True ,
2563+ )
2564+ async for _ in response_stream :
2565+ pass
2566+
2567+ (tx ,) = events
2568+ assert tx ["type" ] == "transaction"
2569+ span = tx ["spans" ][0 ]
2570+ assert span ["op" ] == "gen_ai.chat"
2571+ assert span ["data" ][SPANDATA .GEN_AI_RESPONSE_STREAMING ] is True
2572+
2573+
23912574@pytest .mark .skipif (SKIP_RESPONSES_TESTS , reason = "Responses API not available" )
23922575def test_responses_token_usage_from_response ():
23932576 """Token counts including cached and reasoning tokens are extracted from Responses API."""
0 commit comments