Skip to content

feat(openrouter): capture safeguard_results and alignment in span metadata - #980

Merged
Abhijeet Prasad (AbhiPrasad) merged 1 commit into
mainfrom
abhi/openrouter-safeguard-armo-d74b8ec4
Oct 19, 2026
Merged

Abhijeet Prasad (AbhiPrasad) merged 1 commit into
mainfrom
abhi/openrouter-safeguard-armo-d74b8ec4

Conversation

@AbhiPrasad

@AbhiPrasad Abhijeet Prasad (AbhiPrasad) commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Closes #974
resolves https://linear.app/braintrustdata/issue/SDK-590

AI Summary

Problem

OpenRouter added two plugin-driven fields to its responses:

  • safeguard_results: Armor's verdict for each client tool call, keyed by tool-call id
  • alignment: alignment plugin call records (beta)

They show up on chat results, chat stream chunks, and Responses results. The OpenRouter integration picks span metadata from fixed allowlists, and neither field was on them. So when Armor blocked or flagged a tool call, the span showed no trace of it. That's exactly the signal you want when you're debugging why a tool never ran.

Here's what OpenRouter returns when Armor blocks a call:

"safeguard_results": [
  {
    "type": "dangerous_tool_use",
    "status": {
      "type": "available",
      "tool_uses": {
        "call_nIALvfO1UkLhaH5prmMjaBD4": {
          "outcome": "flagged",
          "type": "evaluated",
          "explanation": "[hard_deny:0]"
        }
      }
    }
  }
]

Streamed chat was worse off than non-streamed chat. OpenRouter sends safeguard_results only on the final chunk, and the chat stream aggregator never read any response-level fields from chunks at all:

chunk 1   delta "<openrouter_armor_blocked ..."
chunk 2   delta "...blocked for safety reasons"
  ...
chunk N   finish_reason=stop, usage={...}, safeguard_results=[...]   <- only here
                                    │                  │
_aggregate_chat_stream ─────────────┘                  └── dropped

Fix

1. One shared key list, added to both response allowlists. Non-streamed chat and Responses (streamed and not) already go through _pick_response_metadata, so these paths only needed the new keys:

# Only present when the request enables the armor / alignment plugins.
_SAFEGUARD_RESPONSE_KEYS = (
    "safeguard_results",
    "alignment",
)
_CHAT_RESPONSE_KEYS = (..., *_SAFEGUARD_RESPONSE_KEYS)
_RESPONSES_RESPONSE_KEYS = (..., *_SAFEGUARD_RESPONSE_KEYS)

2. The chat stream aggregator now returns response metadata. It keeps the last non-None value of each safeguard key it sees, so the final chunk's verdict ends up on the span:

for key in _SAFEGUARD_RESPONSE_KEYS:
    value = _get_field(chunk, key)
    if value is not None:
        response_metadata[key] = value

_aggregate_chat_stream now returns (output, usage, response_metadata), the same shape as _aggregate_responses_stream. That lets _finalize_stream merge metadata in one place for both stream kinds:

 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)}

Streamed chat picks up only the safeguard keys from chunks. It does not go through _pick_response_metadata, because that would also copy model/provider from each chunk and overwrite the request-side values these spans log today.

openrouter:armor_blocked output items

With Armor's output_mode: "custom_item" (Responses API), a blocked call is replaced by an openrouter:armor_blocked output item. The SDK parses it into a typed OutputArmorBlockedItem, and the existing Responses output path already logs it as a clean dict, so this needed no code change:

span.output
├── [0] { type: "openrouter:armor_blocked", call_id, name: "delete_all_files",
│         arguments: '{"path":"/"}', explanation: "[hard_deny:0]", reason, status }
└── [1] { type: "message", content: [...] }

call_id on the blocked item matches the key in metadata.safeguard_results[0].status.tool_uses, so you can join a blocked call to its verdict from the span alone.

🤖 Generated with Claude Code

…adata

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 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T21:56:03.080611Z c1b8d2f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) merged commit 5daa295 into main Oct 19, 2026
98 checks passed
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) deleted the abhi/openrouter-safeguard-armo-d74b8ec4 branch October 19, 2026 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

openrouter: capture safeguard_results and handle armor_blocked output items

1 participant