Skip to content

fix(openai): allowlist metadata pass-through, drop eager response serialization#606

Merged
Abhijeet Prasad (AbhiPrasad) merged 3 commits into
mainfrom
fix/openai-allowlist-metadata
Jul 21, 2026
Merged

fix(openai): allowlist metadata pass-through, drop eager response serialization#606
Abhijeet Prasad (AbhiPrasad) merged 3 commits into
mainfrom
fix/openai-allowlist-metadata

Conversation

@starfolkai

@starfolkai starfolkai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Aligns the OpenAI integration with .agents/skills/sdk-integrations/SKILL.md, following the same pattern as #604 (mistral), #594 (litellm), #603 (livekit), and #583 (anthropic). Audit turned up two issues:

  • Denylist metadata pass-through (real leak vector). Every _parse_params copy (ChatCompletionWrapper, ResponseWrapper, BaseWrapper for Embedding/Moderation/Speech, _AudioFileWrapper, _ImageBaseWrapper) did metadata: {**params, "provider": "openai"} — every non-input request kwarg landed in span metadata. ResponseWrapper._parse_event_from_result did the same on the response side ({k: v for k, v in result.items() if k not in ["output", "usage"]}). That flowed extra_headers, extra_query, extra_body, user-provided Responses metadata, user, safety_identifier, prompt_cache_key, and anything OpenAI adds in future SDK versions straight into telemetry. Three tests actively ratified the header leak.

    Replaced with per-surface allowlist tuples plus a shared _filter_metadata helper that drops NOT_GIVEN/Omit sentinels and serializes response_format inline. Mirrors Anthropic's METADATA_PARAMS.

  • Eager response serialization. _try_to_dict(raw_response) was full-dumping every non-streaming Chat/Response API response just to read usage + choices/outputbt_safe_deep_copy already dumps at log time. Same fix pattern the mistral/litellm/anthropic PRs used: reads via getattr through a small _get_attr helper, plus a narrow _choices_output that dumps only choices (needed for the audio-attachment walker in _process_attachments_in_chat_output). _parse_event_from_result and _extract_moderation_metadata were made dict-or-object aware.

Streaming paths still pre-dump chunks via _try_to_dict(item) because the aggregator reads deeply nested delta fields — that's not the "over-serialize" case the SKILL warns about.

Note on moderation

Added to both chat + responses request allowlists and the responses result allowlist. It's a Braintrust-proxy request parameter ({"model": "omni-moderation-latest"}) that deep-merges with the response's moderation object ({"input": ..., "output": ...}) at log time via merge_dicts. The two existing *_inline_moderation_metadata tests rely on this merge to see logged_moderation["model"].

Test plan

  • No new mocks/fakes. All provider-behavior coverage stays on the existing @pytest.mark.vcr cassette-backed tests.
  • No cassette re-recording. HTTP behavior unchanged; only in-process span shaping was touched.
  • nox -s "test_openai(latest)" — 76/76 pass across test_openai.py (70), test_openai_ddtrace.py (3), test_openai_openrouter_gateway.py (3)
  • nox -s "test_openai(1.92.0)", test_openai(1.77.0), test_openai(1.71.0) — all green
  • nox -s "test_openai_http2_streaming(latest)" — 3/3
  • nox -s "test_btx_openai(latest)" — 5/5
  • nox -s "test_openai_agents(latest)" — 7/7
  • ruff check + ruff format --check on touched files — clean

Three tests updated: assertions that expected metadata["extra_headers"]["X-Stainless-Helper-Method"] == "chat.completions.stream" flipped to "extra_headers" not in span["metadata"] — pins the specific leak vector the fix closes.

🤖 Generated with Claude Code

Created by abhijeet

Slack thread

starfolkbot and others added 3 commits July 21, 2026 01:08
…ialization

Aligns the OpenAI integration with `.agents/skills/sdk-integrations/SKILL.md`,
following the same pattern as #604 (mistral), #594 (litellm), #603 (livekit),
and #583 (anthropic).

- **Allowlist instead of denylist for metadata.** Every `_parse_params` copy
  (`ChatCompletionWrapper`, `ResponseWrapper`, `BaseWrapper` for Embedding/
  Moderation/Speech, `_AudioFileWrapper`, `_ImageBaseWrapper`) previously did
  `metadata: {**params, "provider": "openai"}` — every non-input request kwarg
  landed in span metadata. `ResponseWrapper._parse_event_from_result` did the
  same on the response side. That leaked `extra_headers`, `extra_query`,
  `extra_body`, user-provided Responses `metadata`, `user`, `safety_identifier`,
  `prompt_cache_key`, and anything OpenAI adds in future SDK versions straight
  into telemetry.

  Replaced with per-surface allowlist tuples plus a shared `_filter_metadata`
  helper that drops `NOT_GIVEN`/`Omit` sentinels and serializes
  `response_format` inline. Mirrors Anthropic's `METADATA_PARAMS`.

- **Drop eager response serialization.** `_try_to_dict(raw_response)` was
  full-dumping every non-streaming Chat/Response API response just to read
  `usage`/`choices`/`output` — `bt_safe_deep_copy` already dumps at log time.
  Now reads via `getattr`; a narrow `_choices_output` helper dumps only
  `choices` (needed for the audio-attachment walker). `_parse_event_from_result`
  and `_extract_moderation_metadata` were made dict-or-object aware.

- **Note on `moderation`.** Added to both chat + responses request allowlists
  and the responses result allowlist because it's a Braintrust-proxy request
  parameter that deep-merges with the response's moderation object at log time.

Three tests that asserted `extra_headers` leaked into metadata (`extra_headers
["X-Stainless-Helper-Method"] == "chat.completions.stream"`) flipped to assert
the leak is now blocked.

Test plan:
- No new mocks/fakes; no cassette re-records.
- `nox -s "test_openai(latest)"` — 76/76 pass
- `nox -s "test_openai(1.92.0)"`, `test_openai(1.77.0)`, `test_openai(1.71.0)`
  — all green
- `nox -s "test_openai_http2_streaming(latest)"` — 3/3
- `nox -s "test_btx_openai(latest)"` — 5/5
- `nox -s "test_openai_agents(latest)"` — 7/7
- `ruff check` + `ruff format --check` clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- `_get_attr` only earned its keep in `_extract_moderation_metadata` (which
  legitimately sees both dicts from stream postprocess and pydantic objects
  from non-streaming). Its other callers — `_parse_event_from_result` and
  `_choices_output` — only ever see pydantic responses from the OpenAI SDK,
  so plain `getattr(x, k, None)` suffices.
- `_filter_metadata` was checking `value is None or _is_not_given(value)` —
  the `None` half was redundant defensive belt: `_is_not_given` already handles
  the sentinels, and the old denylist behavior kept None-valued params too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`text_format` on Responses API (`.parse(text_format=SomeModel)`) accepts a
Pydantic BaseModel class exactly like `response_format` on Chat Completions.
It was allowlisted but not passed through `_serialize_response_format`, so
the class reference landed in span metadata as `<class 'x.SomeModel'>` instead
of the JSON schema.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) merged commit 27c4c79 into main Jul 21, 2026
82 checks passed
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) deleted the fix/openai-allowlist-metadata branch July 21, 2026 18:34
Abhijeet Prasad (AbhiPrasad) pushed a commit that referenced this pull request Jul 21, 2026
…reads (#612)

## Summary

Aligns the Strands integration with
`.agents/skills/sdk-integrations/SKILL.md`, following the pattern of
#606 (openai), #604 (mistral), #603 (livekit), #594 (litellm), #583
(anthropic). Audit turned up several issues:

- **Missing span_origin instrumentation name.** Every other integration
has `_INSTRUMENTATION = "<provider>-auto"` + a module-level `start_span`
wrapper; strands was the only one whose spans lacked
`context.span_origin.instrumentation.name`. Added the standard wrapper
and pass `internal={"instrumentation": _INSTRUMENTATION}` explicitly
through the nested `parent.start_span(...)` path, which doesn't route
through the module wrapper.

- **Denylist metrics extraction (real leak vector).**
`_end_model_invoke_span_wrapper` was doing `{k: v for k, v in
metrics.items() if _is_supported_metric_value(v)}` — every numeric key
strands emits (present + future) flowed into the span's top-level
`metrics` bag. Replaced with an explicit allowlist (`latencyMs`,
`timeToFirstByteMs`). Since neither key is spec-listed, they land under
`metadata.strands_metrics` rather than top-level `metrics`.

- **Silently-broken agent metadata extraction.**
`_agent_metrics_and_metadata` did `metrics = getattr(result, "metrics");
if not isinstance(metrics, dict): return {}, {}` — but
`AgentResult.metrics` is an `EventLoopMetrics` **dataclass**, so the
guard discarded everything. Renamed to `_agent_metadata_from_result` and
read `cycle_count` / `accumulated_usage` / `accumulated_metrics` off the
dataclass through a single `_pick_numeric(source, keys)` allowlist
helper. Moved `cycles` from `metrics` to `metadata` (not a spec metric
key).

- **Positional-arg reads.** `_start_agent_span_wrapper` /
`_start_model_invoke_span_wrapper` were `kwargs.get("tools")` /
`kwargs.get("system_prompt")` etc. Strands' signatures put those at
fixed positional slots — switched to `_arg(args, kwargs, N, name)` so
positional callers work.

- **Simplifications (via `/simplify`):** collapsed twin allowlist
helpers into `_pick_numeric`; collapsed the parent-vs-module `if/else`
in `_start_span_for_otel`; replaced the `_bt_parent_otel_span` metadata
back-channel with a direct `parent_otel_span=` kwarg; dropped the
now-orphan `metrics=` parameter from `_end_span_for_otel`; trimmed the
verbose `wrap_strands_tracer` and `_InvalidOtelSpanKey` docstrings.

## Known SKILL gaps NOT fixed here

Each deserves its own PR/discussion — flagging so they don't get lost:

1. **`metadata.provider` on LLM spans.** Strands' `Tracer` only receives
`model_id`, not the model class — provider inference would require
brittle id-pattern matching.
2. **Tokens under `metadata.strands_usage` rather than `metrics.tokens`
/ `prompt_tokens` / `completion_tokens`.** Fixing correctly means
deciding whether model-invoke should stay typed `llm` at all — Strands'
`OpenAIModel` / `AnthropicModel` delegate to the underlying provider
SDK, which per the SKILL's "framework llm-like spans that delegate" rule
should NOT be typed `llm`.
3. **No `test_auto_strands.py` subprocess auto-instrument test.**
Strands is wired into `auto.py` but lacks the subprocess parity test
other integrations have.

## Test plan

- [x] **No new mocks/fakes.** Existing cassette-backed VCR test
(`test_strands_openai_agent_traces_native_otel_lifecycle`) covers the
behavior; added a `span_origin.instrumentation.name == "strands-auto"`
assertion on every emitted span.
- [x] **No cassette re-recording.** HTTP behavior is unchanged; only
in-process span shaping was touched.
- [x] `cd py && nox -s "test_strands(latest)"` — 2/2 pass
- [x] `cd py && nox -s "test_strands(1.20.0)"` — 2/2 pass
- [x] `ruff check` + `ruff format --check` on touched files — clean
- [x] `nox -s pylint` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- sfk:created-approved-by -->
Created by abhijeet

<!-- sfk:slack-thread -->
[Slack
thread](https://starfolkai.slack.com/archives/C0AQDETAVT3/p1784659948425149?thread_ts=1784659948.425149&cid=C0AQDETAVT3)

Co-authored-by: Starfolk <noreply@starfolk.ai>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Abhijeet Prasad (AbhiPrasad) pushed a commit that referenced this pull request Jul 21, 2026
…eshape (#610)

## Summary

Aligns the pydantic_ai integration with
`.agents/skills/sdk-integrations/SKILL.md`, following the same pattern
as #606 (openai), #608 (openrouter), and #604/#603/#594
(anthropic/livekit/litellm/mistral). Audit turned up two issues:

- **Denylist metadata pass-through (real leak vector).**
`_build_agent_input_and_metadata` and
`_build_direct_model_input_and_metadata` did `for key, value in
kwargs.items(): if key not in [...]: input_data[key] = value` — every
non-`deps` / non-`{model,messages}` kwarg landed in span input. That
flowed `usage` (a mutable counter), `event_stream_handler` (a callable),
`infer_name`, `instrument`, and anything pydantic_ai adds in future SDK
versions straight into telemetry. `deps` (user's app-level container —
commonly holds API keys / DB connections) was the sole excluded key on
the agent path; on the direct-model path there was no exclusion at all.

Replaced with per-surface allowlist tuples (`_AGENT_INPUT_ALLOWLIST`,
`_DIRECT_MODEL_INPUT_ALLOWLIST`) covering the known kwargs across
`Agent.run` / `run_sync` / `run_stream` / `run_stream_events` /
`to_cli_sync` and `direct.model_request*`. Explicitly excludes `deps`,
`usage`, `event_stream_handler`, `infer_name`, `instrument`.

- **Eager message reshaping dropping fields.** `_shape_message` /
`_shape_content_part` / `_shape_model_response` reshaped every message
into shallow dicts via a hard-coded field allowlist (`_MESSAGE_FIELDS`,
`_PART_FIELDS`, `_RESPONSE_FIELDS`) even when no binary content needed
materialization. That dropped real pydantic_ai v2 fields —
`instructions`, `run_id`, `conversation_id`, `metadata`, `state`,
`provider_details`, `outcome`, ... — and burned CPU rebuilding dicts
that `bt_safe_deep_copy` (which already handles dataclasses + Pydantic
models) would produce anyway at log time.

Added `_has_binary_leaf` walker; the four shape helpers now
short-circuit to the raw object when no binary is present. Reshape (and
its field allowlist) only runs when a binary attachment actually needs
materialization.

Also:
- Extracted `_shape_toolset(ts)` from a nested 20-line block inside
`_build_agent_input_and_metadata`.
- Trimmed docstrings/comments that just restated the helper name. Kept
the ones explaining non-obvious *why* (portal-thread context
propagation, wrapper-vs-leaf token ownership, 1.78.0 ToolManager alias,
`_system_prompts` private-API access).
- Hoisted `import inspect` from inside `_shape_type` to module top.

## Test plan

- [x] **No new mocks/fakes.** All provider-behavior coverage stays on
the existing `@pytest.mark.vcr` cassette-backed tests.
- [x] **No cassette re-recording.** HTTP behavior unchanged; only
in-process input/shape logic was touched.
- [x] `nox -s "test_pydantic_ai_integration(latest)"` — 68/68 pass
(`pydantic-ai==2.13.0`)
- [x] `nox -s "test_pydantic_ai_integration(1.10.0)"` — 68/68 pass
(min-supported matrix version)
- [x] `nox -s "test_pydantic_ai_wrap_openai(latest)"` — green
- [x] `nox -s "test_pydantic_ai_logfire(latest)"` — 1/1 pass

Two new tests pin the security invariant:
- `test_agent_input_kwargs_are_allowlisted` — asserts `deps` / `usage` /
`event_stream_handler` / `infer_name` never land in span input;
allowlisted kwargs (`instructions`, `usage_limits`, `retries`,
`conversation_id`, `metadata`) do.
- `test_direct_model_request_kwargs_are_allowlisted` — asserts
`instrument` never leaks; `model_settings` / `model_request_parameters`
do.

One existing test replaced:
`test_v2_message_and_response_fields_are_shaped` →
`test_shape_message_and_response_pass_through_when_no_binary` — the old
test was locking in the reshape that was losing v2 fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- sfk:created-approved-by -->
Created by abhijeet

<!-- sfk:slack-thread -->
[Slack
thread](https://starfolkai.slack.com/archives/C0AQDETAVT3/p1784659060950489?thread_ts=1784659060.950489&cid=C0AQDETAVT3)

---------

Co-authored-by: Starfolk <noreply@starfolk.ai>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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.

2 participants