Skip to content

Commit a0b4866

Browse files
authored
fix(openai): trace computer use and access programs (#876)
## Summary - Trace Agents computer-use, browser-authentication, and approval-result items in tool spans. - Convert screenshot data URLs from computer-use outputs into Braintrust attachments. - Capture Responses `access_programs` in request and response metadata, with defensive reads for older OpenAI pins. Fixes #869 ## Live traces - [Computer-use span with screenshot attachment](https://www.braintrust.dev/app/Braintrust%20SDKs/p/openai-computer-use-access-programs-live-20261005/logs?r=8fa3bd730d96b150c3dcecaa729331d5&s=1befb8072daa3a4b&tvt=trace) - [Responses `access_programs` trace](https://www.braintrust.dev/app/Braintrust%20SDKs/p/openai-computer-use-access-programs-live-20261005/logs?r=bf79e4b24db593bd142344dff5f63208&s=ec148b3661e85412&tvt=trace)
1 parent c2bb4f1 commit a0b4866

3 files changed

Lines changed: 190 additions & 4 deletions

File tree

‎py/src/braintrust/integrations/openai/cassettes/latest/test_openai_responses_access_programs.yaml‎

Lines changed: 119 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎py/src/braintrust/integrations/openai/test_openai.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ def _supports_agents_api() -> bool:
9393
return True
9494

9595

96+
def _supports_responses_access_programs() -> bool:
97+
try:
98+
from openai.resources.responses import Responses
99+
100+
return "access_programs" in inspect.signature(Responses.create).parameters
101+
except (ImportError, AttributeError, ValueError):
102+
return False
103+
104+
96105
@pytest.mark.vcr
97106
def test_openai_chat_metrics(memory_logger):
98107
assert not memory_logger.pop()
@@ -189,6 +198,11 @@ def test_openai_responses_metrics(memory_logger):
189198
assert TEST_MODEL in span["metadata"]["model"]
190199
assert span["metadata"]["provider"] == "openai"
191200
assert span["metadata"]["instructions"] == "Just the number please"
201+
if hasattr(response, "access_programs"):
202+
# Current OpenAI cassettes include the field with a null value. Older
203+
# provider pins do not expose it; neither shape should add null metadata.
204+
assert response.access_programs is None
205+
assert "access_programs" not in span["metadata"]
192206
assert TEST_PROMPT in str(span["input"])
193207
assert len(span["output"]) > 0
194208
span_output_text = span["output"][0]["content"][0]["text"]
@@ -239,6 +253,25 @@ class NumberAnswer(BaseModel):
239253
assert span["output"][0]["content"][0]["parsed"]["reasoning"] == parse_response.output_parsed.reasoning
240254

241255

256+
@pytest.mark.vcr
257+
def test_openai_responses_access_programs(memory_logger):
258+
if not _supports_responses_access_programs():
259+
pytest.skip("Responses access_programs is not available in this SDK version")
260+
261+
access_programs = {"cyber": "standard"}
262+
client = wrap_openai(openai.OpenAI())
263+
response = client.responses.create(
264+
model=TEST_MODEL,
265+
input="Say hello in one word.",
266+
access_programs=access_programs,
267+
)
268+
269+
spans = memory_logger.pop()
270+
assert len(spans) == 1
271+
assert _try_to_dict(response.access_programs) == access_programs
272+
assert _try_to_dict(spans[0]["metadata"]["access_programs"]) == access_programs
273+
274+
242275
@pytest.mark.vcr
243276
def test_openai_chat_completion_inline_moderation_metadata(memory_logger):
244277
if not _supports_inline_moderation():

‎py/src/braintrust/integrations/openai/tracing.py‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def start_span(*args, **kwargs):
105105
"conversation",
106106
"verbosity",
107107
"moderation",
108+
"access_programs",
108109
)
109110

110111
_RESPONSES_RESULT_METADATA_KEYS = (
@@ -114,6 +115,7 @@ def start_span(*args, **kwargs):
114115
"created_at",
115116
"moderation",
116117
"service_tier",
118+
"access_programs",
117119
"object",
118120
"background",
119121
"incomplete_details",
@@ -1183,6 +1185,10 @@ def _log_response_tool_spans(output: Any, *, parent_export: str | None) -> None:
11831185
"command_execution": ("command", "cwd"),
11841186
"mcp_call": ("arguments",),
11851187
"web_search_call": ("action",),
1188+
"computer_use_call": ("title",),
1189+
"browser_authentication_request": ("request_id",),
1190+
"computer_use_approval_request": ("request_id", "request"),
1191+
"computer_use_approval_request_result": ("request_id",),
11861192
"create_subagent_call": ("content", "model", "reasoning_effort"),
11871193
"send_subagent_input_call": ("content", "recipient_agent_id"),
11881194
"resume_subagent_call": ("recipient_agent_id",),
@@ -1194,6 +1200,7 @@ def _log_response_tool_spans(output: Any, *, parent_export: str | None) -> None:
11941200
_AGENT_TOOL_ITEM_OUTPUT_KEYS = {
11951201
"command_execution": ("output", "exit_code", "duration_ms"),
11961202
"mcp_call": ("output",),
1203+
"computer_use_approval_request_result": ("response",),
11971204
}
11981205

11991206
_AGENT_TURN_EVENTS = {
@@ -1272,7 +1279,13 @@ def _agent_tool_span_name(item: Any) -> str:
12721279

12731280

12741281
def _agent_tool_span_data(item: Any, keys: tuple[str, ...]) -> Any:
1275-
values = clean_nones({key: getattr(item, key, None) for key in keys})
1282+
values = clean_nones(
1283+
{
1284+
key: _try_to_dict(value) if key in {"request", "response"} else value
1285+
for key in keys
1286+
if (value := getattr(item, key, None)) is not None
1287+
}
1288+
)
12761289
if not values:
12771290
return None
12781291
if keys == ("arguments",):
@@ -1286,7 +1299,7 @@ def _agent_tool_span_metadata(item: Any) -> dict[str, Any]:
12861299
"tool_type": item.type,
12871300
"tool_id": item.id,
12881301
"call_id": getattr(item, "call_id", None),
1289-
"status": item.status,
1302+
"status": getattr(item, "status", None),
12901303
"turn_id": item.turn_id,
12911304
"server_label": getattr(item, "server_label", None),
12921305
"agent_id": getattr(item, "agent_id", None),
@@ -1299,7 +1312,7 @@ def _agent_tool_span_error(item: Any) -> Any:
12991312
error = getattr(item, "error", None)
13001313
if error is not None:
13011314
return error
1302-
if item.status == "failed":
1315+
if getattr(item, "status", None) == "failed":
13031316
return "Agent tool call failed"
13041317
return None
13051318

@@ -1383,7 +1396,7 @@ def _observe_completed_item(self, item: Any) -> None:
13831396
error = _agent_tool_span_error(item)
13841397
if error is not None:
13851398
tool_span.log(error=error)
1386-
output = _agent_tool_span_data(item, _AGENT_TOOL_ITEM_OUTPUT_KEYS.get(item.type, ()))
1399+
output = _agent_tool_span_output(item)
13871400
if output is not None:
13881401
tool_span.log(output=output)
13891402

@@ -1412,6 +1425,27 @@ def finish(self) -> None:
14121425
self.span.end()
14131426

14141427

1428+
def _agent_tool_span_output(item: Any) -> Any:
1429+
if item.type == "computer_use_call":
1430+
output = getattr(item, "output", None)
1431+
screenshot = getattr(output, "image_url", None)
1432+
if screenshot is None and isinstance(output, Mapping):
1433+
screenshot = output.get("image_url")
1434+
if screenshot is not None:
1435+
resolved = _materialize_attachment(screenshot, prefix="computer_use_screenshot")
1436+
if resolved is not None:
1437+
output_data = _try_to_dict(output)
1438+
if isinstance(output_data, dict):
1439+
return {
1440+
**output_data,
1441+
"image_url": resolved.multimodal_part_payload["image_url"],
1442+
}
1443+
return resolved.multimodal_part_payload
1444+
return output
1445+
1446+
return _agent_tool_span_data(item, _AGENT_TOOL_ITEM_OUTPUT_KEYS.get(item.type, ()))
1447+
1448+
14151449
class AgentSessionWrapper:
14161450
def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..., Any] | None) -> None:
14171451
self.create_fn = create_fn

0 commit comments

Comments
 (0)