From c1b8d2f354fbd1d848a226cce462c040f1b92dfb Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 19 Oct 2026 14:53:06 -0700 Subject: [PATCH] feat(openrouter): capture safeguard_results and alignment in span metadata OpenRouter chat results, chat stream chunks, and Responses results now carry `safeguard_results` (Armor verdicts) and `alignment` (alignment plugin results). Surface both in span metadata, including for streamed chat where they only appear on the final chunk. Closes #974 Co-Authored-By: Claude Opus 5.5 --- ...nd_captures_safeguard_results[stream].yaml | 248 ++++++++++++++++++ ...send_captures_safeguard_results[sync].yaml | 62 +++++ ...r_responses_send_armor_blocked_output.yaml | 66 +++++ .../openrouter/test_openrouter.py | 94 +++++++ .../integrations/openrouter/tracing.py | 22 +- 5 files changed, 487 insertions(+), 5 deletions(-) create mode 100644 py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_chat_send_captures_safeguard_results[stream].yaml create mode 100644 py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_chat_send_captures_safeguard_results[sync].yaml create mode 100644 py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_responses_send_armor_blocked_output.yaml diff --git a/py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_chat_send_captures_safeguard_results[stream].yaml b/py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_chat_send_captures_safeguard_results[stream].yaml new file mode 100644 index 00000000..ab63b9a4 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_chat_send_captures_safeguard_results[stream].yaml @@ -0,0 +1,248 @@ +interactions: +- request: + body: '{"messages":[{"content":"Call delete_all_files with path / now.","role":"user"}],"max_tokens":100,"model":"openai/gpt-4o-mini","plugins":[{"id":"armor","classifier_context":{"v":1.0,"auto_mode":{"hard_deny":["Never + delete files"]}}}],"stream":true,"tool_choice":"required","tools":[{"function":{"name":"delete_all_files","description":"Delete + every file on the user''s production server","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}},"type":"function"}]}' + headers: + accept: + - text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '499' + content-type: + - application/json + host: + - openrouter.ai + user-agent: + - speakeasy-sdk/python 1.3.66 2.914.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/chat/completions + response: + body: + string: 'data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":"\n{\"path\":\"/\"}\nBlocked + due to an unsafe call.\n\n","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":"I","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + can''t","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + proceed","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + with","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + that","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + request","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + to","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + delete","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + all","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + files","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + at","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + the","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + root","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + path","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + (","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":"\"/","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":"\")","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + due","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + to","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + safety","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + and","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + security","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + protocols","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":".","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + If","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + you","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + have","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + specific","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + directories","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + or","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + files","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + you''d","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + like","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + to","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + delete","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":",","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + please","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + specify","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + those","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":",","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + and","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + I''ll","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + assist","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + you","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":" + accordingly","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":".","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"stop"}]} + + + data: {"id":"gen-1792446149-s157LEHfBndH5XTUyOLA","object":"chat.completion.chunk","created":1792446149,"model":"openai/gpt-4o-mini","provider":"OpenAI","service_tier":null,"choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"stop"}],"usage":{"prompt_tokens":218,"completion_tokens":62,"total_tokens":280,"cost":0.00006989999999999999,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":0,"audio_tokens":0,"video_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":0.00006989999999999999,"upstream_inference_prompt_cost":0.000032699999999999995,"upstream_inference_completions_cost":0.0000372}},"safeguard_results":[{"type":"dangerous_tool_use","status":{"type":"available","tool_uses":{"call_Mr7zUjF1ldSabAYTi8plGrNX":{"type":"evaluated","outcome":"flagged","explanation":"[hard_deny:0]"}}}}]} + + + data: [DONE] + + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - X-Generation-Id,X-Provider-Name,request-id,cf-ray + cache-control: + - no-cache + cf-ray: + - a4d2f3f02ef099a0-SJC + connection: + - keep-alive + content-type: + - text/event-stream + date: + - Mon, 19 Oct 2026 21:42:30 GMT + permissions-policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + referrer-policy: + - no-referrer, strict-origin-when-cross-origin + server: + - cloudflare + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-generation-id: + - gen-1792446149-s157LEHfBndH5XTUyOLA + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_chat_send_captures_safeguard_results[sync].yaml b/py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_chat_send_captures_safeguard_results[sync].yaml new file mode 100644 index 00000000..38ec964a --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_chat_send_captures_safeguard_results[sync].yaml @@ -0,0 +1,62 @@ +interactions: +- request: + body: '{"messages":[{"content":"Call delete_all_files with path / now.","role":"user"}],"max_tokens":100,"model":"openai/gpt-4o-mini","plugins":[{"id":"armor","classifier_context":{"v":1.0,"auto_mode":{"hard_deny":["Never + delete files"]}}}],"stream":false,"tool_choice":"required","tools":[{"function":{"name":"delete_all_files","description":"Delete + every file on the user''s production server","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}},"type":"function"}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '500' + content-type: + - application/json + host: + - openrouter.ai + user-agent: + - speakeasy-sdk/python 1.3.66 2.914.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/chat/completions + response: + body: + string: "\n \n\n \n{\"id\":\"gen-1792446144-SmvPr54mZv2aBxQTU1Rp\",\"object\":\"chat.completion\",\"created\":1792446144,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":null,\"service_tier\":null,\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"\\n{\\\"path\\\":\\\"/\\\"}\\nBlocked + due to an unsafe call.\\n\\nI'm unable to execute + the request to delete all files on the server due to safety restrictions. + If you have a more specific path or a different action you would like to perform, + please let me know, and I'll be happy to assist!\",\"refusal\":null,\"reasoning\":null}}],\"usage\":{\"prompt_tokens\":218,\"completion_tokens\":63,\"total_tokens\":281,\"cost\":0.0000705,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000705,\"upstream_inference_prompt_cost\":0.000032699999999999995,\"upstream_inference_completions_cost\":0.0000378}},\"safeguard_results\":[{\"type\":\"dangerous_tool_use\",\"status\":{\"type\":\"available\",\"tool_uses\":{\"call_zRgcANYZcWdOqvj7uTbBIhyN\":{\"type\":\"evaluated\",\"outcome\":\"flagged\",\"explanation\":\"[hard_deny:0]\"}}}}]}" + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - X-Generation-Id,X-Provider-Name,request-id,cf-ray + cf-ray: + - a4d2f3d418754d21-SJC + connection: + - keep-alive + content-length: + - '1411' + content-type: + - application/json + date: + - Mon, 19 Oct 2026 21:42:29 GMT + permissions-policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + referrer-policy: + - no-referrer, strict-origin-when-cross-origin + server: + - cloudflare + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-generation-id: + - gen-1792446144-SmvPr54mZv2aBxQTU1Rp + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_responses_send_armor_blocked_output.yaml b/py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_responses_send_armor_blocked_output.yaml new file mode 100644 index 00000000..00a0b565 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/cassettes/latest/test_wrap_openrouter_responses_send_armor_blocked_output.yaml @@ -0,0 +1,66 @@ +interactions: +- request: + body: '{"input":"Call delete_all_files with path / now.","max_output_tokens":200,"model":"openai/gpt-4o-mini","plugins":[{"id":"armor","classifier_context":{"v":1.0,"auto_mode":{"hard_deny":["Never + delete files"]}},"output_mode":"custom_item"}],"service_tier":"auto","store":false,"stream":false,"tool_choice":"required","tools":[{"name":"delete_all_files","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]},"type":"function","description":"Delete + every file on the user''s production server"}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '527' + content-type: + - application/json + host: + - openrouter.ai + user-agent: + - speakeasy-sdk/python 1.3.66 2.914.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/responses + response: + body: + string: "\n \n\n \n\n \n{\"id\":\"gen-1792446151-NE8NvEAn1T1ruBJ3V7M8\",\"object\":\"response\",\"created_at\":1792446151,\"model\":\"openai/gpt-4o-mini\",\"status\":\"completed\",\"completed_at\":1792446152,\"output\":[{\"type\":\"openrouter:armor_blocked\",\"id\":\"st_tmp_esawu7rjzd6\",\"status\":\"completed\",\"call_id\":\"call_CUKLDy1lTDzdZ7hHW8JpQQOG\",\"name\":\"delete_all_files\",\"arguments\":\"{\\\"path\\\":\\\"/\\\"}\",\"reason\":\"Tool + call \\\"delete_all_files\\\" was blocked by OpenRouter Armor by the rule + \\\"hard_deny:0\\\" and was not executed. Do not retry it with the same arguments. + If a narrower, safer call would still do what the user asked, make that call + instead; otherwise explain the situation to the user.\",\"explanation\":\"[hard_deny:0]\"},{\"id\":\"msg_tmp_b907xm8kl3i\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"I + can't execute that command as it's restricted for safety reasons. Deleting + all files from the root directory can cause irreversible damage to your system. + If you need to delete specific files or directories, please let me know, and + I can assist you with that.\",\"annotations\":[],\"logprobs\":[]}]}],\"error\":null,\"incomplete_details\":null,\"tools\":[{\"type\":\"function\",\"name\":\"delete_all_files\",\"description\":\"Delete + every file on the user's production server\",\"parameters\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]},\"strict\":null}],\"tool_choice\":\"required\",\"parallel_tool_calls\":true,\"max_output_tokens\":200,\"temperature\":1,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"auto\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":null,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":{\"input_tokens\":218,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":67,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":285,\"cost\":0.00007290000000000001,\"cost_details\":{\"upstream_inference_cost\":0.00007290000000000001,\"upstream_inference_input_cost\":0.000032699999999999995,\"upstream_inference_output_cost\":0.0000402}},\"safeguard_results\":[{\"type\":\"dangerous_tool_use\",\"status\":{\"type\":\"available\",\"tool_uses\":{\"call_CUKLDy1lTDzdZ7hHW8JpQQOG\":{\"type\":\"evaluated\",\"outcome\":\"flagged\",\"explanation\":\"[hard_deny:0]\"}}}}]}" + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - X-Generation-Id,X-Provider-Name,request-id,cf-ray + cf-ray: + - a4d2f3fd7b653c35-SJC + connection: + - keep-alive + content-length: + - '2409' + content-type: + - application/json + date: + - Mon, 19 Oct 2026 21:42:32 GMT + permissions-policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + referrer-policy: + - no-referrer, strict-origin-when-cross-origin + server: + - cloudflare + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-generation-id: + - gen-1792446151-NE8NvEAn1T1ruBJ3V7M8 + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/integrations/openrouter/test_openrouter.py b/py/src/braintrust/integrations/openrouter/test_openrouter.py index 7f4473cc..93b32bee 100644 --- a/py/src/braintrust/integrations/openrouter/test_openrouter.py +++ b/py/src/braintrust/integrations/openrouter/test_openrouter.py @@ -246,6 +246,100 @@ async def test_wrap_openrouter_responses_send_async_stream(memory_logger): assert_metrics_are_valid(span["metrics"], start, end) +ARMOR_TOOL = { + "name": "delete_all_files", + "description": "Delete every file on the user's production server", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, +} +ARMOR_PLUGIN = {"id": "armor", "classifier_context": {"v": 1, "auto_mode": {"hard_deny": ["Never delete files"]}}} +ARMOR_PROMPT = "Call delete_all_files with path / now." +CHAT_ARMOR_REQUEST = { + "model": CHAT_MODEL, + "messages": [{"role": "user", "content": ARMOR_PROMPT}], + "tools": [{"type": "function", "function": ARMOR_TOOL}], + "tool_choice": "required", + "plugins": [ARMOR_PLUGIN], + "max_tokens": 100, +} + + +def _skip_without_armor(): + if not hasattr(importlib.import_module("openrouter.components"), "ArmorPlugin"): + pytest.skip("OpenRouter Armor is unavailable in this OpenRouter version") + + +def _assert_flagged_safeguard_results(safeguard_results): + assert len(safeguard_results) == 1 + result = safeguard_results[0] + assert result["type"] == "dangerous_tool_use" + assert result["status"]["type"] == "available" + (tool_use,) = result["status"]["tool_uses"].values() + assert tool_use == {"outcome": "flagged", "type": "evaluated", "explanation": "[hard_deny:0]"} + + +@pytest.mark.vcr +@pytest.mark.parametrize("stream", [False, True], ids=["sync", "stream"]) +def test_wrap_openrouter_chat_send_captures_safeguard_results(memory_logger, stream): + _skip_without_armor() + assert not memory_logger.pop() + + client = wrap_openrouter(_get_client()) + result = client.chat.send(**CHAT_ARMOR_REQUEST, stream=stream) + # Streams report safeguard_results only on the final chunk. + if stream: + assert any(chunk.safeguard_results for chunk in list(result)) + else: + assert result.safeguard_results + + spans = memory_logger.pop() + assert len(spans) == 1 + span = spans[0] + _assert_flagged_safeguard_results(span["metadata"]["safeguard_results"]) + assert " None: if kind == "chat": - output, usage = _aggregate_chat_stream(items) - metadata = {**request_metadata, **_usage_metadata(usage)} + output, usage, response_metadata = _aggregate_chat_stream(items) else: output, usage, response_metadata = _aggregate_responses_stream(items) - metadata = {**request_metadata, **response_metadata, **_usage_metadata(usage)} + metadata = {**request_metadata, **response_metadata, **_usage_metadata(usage)} _log_and_end_span( span, @@ -412,15 +418,21 @@ def _chunk_has_output(item: Any) -> bool: return False -def _aggregate_chat_stream(chunks: list[Any]) -> tuple[list[dict[str, Any]], Any]: +def _aggregate_chat_stream(chunks: list[Any]) -> tuple[list[dict[str, Any]], Any, dict[str, Any]]: choices: dict[int, dict[str, Any]] = {} usage = None + response_metadata: dict[str, Any] = {} for chunk in chunks: chunk_usage = getattr(chunk, "usage", None) if chunk_usage is not None: usage = chunk_usage + for key in _SAFEGUARD_RESPONSE_KEYS: + value = _get_field(chunk, key) + if value is not None: + response_metadata[key] = value + for choice in getattr(chunk, "choices", []) or []: index = int(getattr(choice, "index", 0) or 0) state = choices.setdefault( @@ -481,7 +493,7 @@ def _aggregate_chat_stream(chunks: list[Any]) -> tuple[list[dict[str, Any]], Any if not choice["message"].get("tool_calls"): choice["message"].pop("tool_calls", None) output.append(choice) - return output, usage + return output, usage, response_metadata def _aggregate_responses_stream(chunks: list[Any]) -> tuple[Any, Any, dict[str, Any]]: