Skip to content

Commit 6ce081b

Browse files
committed
Merge branch 'master' into feat/span-first
2 parents c1939d3 + 6ea663f commit 6ce081b

File tree

6 files changed

+126
-5
lines changed

6 files changed

+126
-5
lines changed

.github/workflows/ai-integration-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
token: ${{ secrets.GITHUB_TOKEN }}
3535

3636
- name: Run Python SDK Tests
37-
uses: getsentry/testing-ai-sdk-integrations@285c012e522f241581534dfc89bd99ec3b1da4f6
37+
uses: getsentry/testing-ai-sdk-integrations@6b1f51ec8af03e19087df452b426aa7e46d2b20a
3838
env:
3939
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4040
with:

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
steps:
2323
- name: Get auth token
2424
id: token
25-
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
25+
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
2626
with:
2727
app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
2828
private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}

sentry_sdk/ai/utils.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -556,10 +556,25 @@ def _truncate_single_message_content_if_present(
556556
return message
557557
content = message["content"]
558558

559-
if not isinstance(content, str) or len(content) <= max_chars:
559+
if isinstance(content, str):
560+
if len(content) <= max_chars:
561+
return message
562+
message["content"] = content[:max_chars] + "..."
563+
return message
564+
565+
if isinstance(content, list):
566+
remaining = max_chars
567+
for item in content:
568+
if isinstance(item, dict) and "text" in item:
569+
text = item["text"]
570+
if isinstance(text, str):
571+
if len(text) > remaining:
572+
item["text"] = text[:remaining] + "..."
573+
remaining = 0
574+
else:
575+
remaining -= len(text)
560576
return message
561577

562-
message["content"] = content[:max_chars] + "..."
563578
return message
564579

565580

sentry_sdk/integrations/langchain.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,8 @@ def on_llm_end(
554554
finish_reason = generation.generation_info.get("finish_reason")
555555
if finish_reason is not None:
556556
span.set_data(
557-
SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, finish_reason
557+
SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS,
558+
[finish_reason],
558559
)
559560
except AttributeError:
560561
pass

tests/integrations/langchain/test_langchain.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,12 @@ def test_langchain_agent(
297297
f"and include_prompts={include_prompts}"
298298
)
299299

300+
# Verify finish_reasons is always an array of strings
301+
assert chat_spans[0]["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == [
302+
"function_call"
303+
]
304+
assert chat_spans[1]["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == ["stop"]
305+
300306
# Verify that available tools are always recorded regardless of PII settings
301307
for chat_span in chat_spans:
302308
span_data = chat_span.get("data", {})

tests/test_ai_monitoring.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,105 @@ def test_single_message_truncation(self):
312312
assert user_msgs[0]["content"].endswith("...")
313313
assert len(user_msgs[0]["content"]) < len(large_content)
314314

315+
def test_single_message_truncation_list_content_exceeds_limit(self):
316+
"""Test that list-based content (e.g. pydantic-ai multimodal format) is truncated."""
317+
large_text = "A" * 200_000
318+
319+
messages = [
320+
{
321+
"role": "user",
322+
"content": [
323+
{"type": "text", "text": large_text},
324+
],
325+
},
326+
]
327+
328+
result, _ = truncate_messages_by_size(messages)
329+
330+
text_part = result[0]["content"][0]
331+
assert text_part["text"].endswith("...")
332+
assert len(text_part["text"]) == MAX_SINGLE_MESSAGE_CONTENT_CHARS + 3
333+
334+
def test_single_message_truncation_list_content_under_limit(self):
335+
"""Test that small text parts are preserved when non-text parts push size over byte limit."""
336+
short_text = "Hello world"
337+
large_data_url = "data:image/png;base64," + "A" * 200_000
338+
339+
messages = [
340+
{
341+
"role": "user",
342+
"content": [
343+
{"type": "text", "text": short_text},
344+
{"type": "image_url", "image_url": {"url": large_data_url}},
345+
],
346+
},
347+
]
348+
349+
result, _ = truncate_messages_by_size(messages)
350+
351+
text_part = result[0]["content"][0]
352+
assert text_part["text"] == short_text
353+
354+
def test_single_message_truncation_list_content_mixed_parts(self):
355+
"""Test truncation with mixed content types (text + non-text parts)."""
356+
max_chars = 50
357+
large_data_url = "data:image/png;base64," + "X" * 200_000
358+
359+
messages = [
360+
{
361+
"role": "user",
362+
"content": [
363+
{"type": "text", "text": "A" * 30},
364+
{"type": "image_url", "image_url": {"url": large_data_url}},
365+
{"type": "text", "text": "B" * 30},
366+
],
367+
},
368+
]
369+
370+
result, _ = truncate_messages_by_size(
371+
messages, max_single_message_chars=max_chars
372+
)
373+
374+
parts = result[0]["content"]
375+
# First text part uses 30 chars of the 50 budget
376+
assert parts[0]["text"] == "A" * 30
377+
# Image part is unchanged
378+
assert parts[1]["type"] == "image_url"
379+
# Second text part is truncated to remaining 20 chars
380+
assert parts[2]["text"] == "B" * 20 + "..."
381+
382+
def test_single_message_truncation_list_content_multiple_text_parts(self):
383+
"""Test that budget is distributed across multiple text parts."""
384+
max_chars = 10
385+
# Two large text parts that together exceed 128KB byte limit
386+
messages = [
387+
{
388+
"role": "user",
389+
"content": [
390+
{"type": "text", "text": "A" * 100_000},
391+
{"type": "text", "text": "B" * 100_000},
392+
],
393+
},
394+
]
395+
396+
result, _ = truncate_messages_by_size(
397+
messages, max_single_message_chars=max_chars
398+
)
399+
400+
parts = result[0]["content"]
401+
# First part is truncated to the full budget
402+
assert parts[0]["text"] == "A" * 10 + "..."
403+
# Second part gets truncated to 0 chars + ellipsis
404+
assert parts[1]["text"] == "..."
405+
406+
@pytest.mark.parametrize("content", [None, 42, 3.14, True])
407+
def test_single_message_truncation_non_str_non_list_content(self, content):
408+
messages = [{"role": "user", "content": content}]
409+
410+
result, _ = truncate_messages_by_size(messages)
411+
412+
assert result[0]["content"] is content
413+
315414

316415
class TestTruncateAndAnnotateMessages:
317416
def test_only_keeps_last_message(self, sample_messages):

0 commit comments

Comments
 (0)