Skip to content

feat(python): add openinference-instrumentation-typesafe - #3773

Merged
mikeldking merged 5 commits into
mainfrom
claude/adoring-heisenberg-714zx6
Sep 18, 2026
Merged

mikeldking merged 5 commits into
mainfrom
claude/adoring-heisenberg-714zx6

Conversation

@axiomofjoy

@axiomofjoy axiomofjoy commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Adds openinference-instrumentation-typesafe, an instrumentor for the TypeSafe AI Python SDK (typesafe-sdk >= 0.6.0).

What it traces

TypeSafeClient.system_one and AsyncTypeSafeClient.system_one become OpenInference LLM spans.

A System One call isn't a chat exchange. You send a state plus a map of typed questions (Noul, Choice, Score) and get back one typed answer per question — there's no message list on either side. So the span records the request and response bodies as input.value and output.value and stops there; no llm.input_messages / llm.output_messages. The questions map rides in llm.invocation_parameters, where it plays the same role a JSON response schema plays for chat-completion APIs.

Beyond that: llm.provider (typesafe), llm.request.model_name and llm.response.model_name (e.g. jev-latest → jev-1.13.0), and llm.token_count.{prompt,completion,total}.

Everything is built from the shared helpers in openinference.instrumentation, and spans go through OITracer — so TraceConfig masking, context attributes (session, user, metadata, tags) and suppress_tracing all work. Errors record the exception and set ERROR status.

Notable choices

  • Patched through the SDK's public surface. The wrappers target typesafe_sdk.TypeSafeClient / typesafe_sdk.AsyncTypeSafeClient rather than the typesafe_sdk._core.client.* modules that define them. wrapt patches the same class object either way, and the private path carries no compatibility promise.
  • python/uv.toml gets a typesafe-sdk exemption. The instrumentor's floor, 0.6.0, was published more recently than CI's rolling 3-day exclude-newer cutoff, so resolution fails without it. It uses the far-future date the file already documents, deliberately: a per-package timestamp overrides the global cutoff, so a real date would become a permanent ceiling and quietly turn the typesafe-latest canary into a no-op.
  • No VCR cassettes. The SDK speaks httpx2, which vcrpy doesn't patch, so the tests inject an httpx2.MockTransport that records request bodies and replays a canned System One response. Fully offline, no API key.

Testing

Covers sync and async, structured state, raw-dict questions, the client-level default model, extra_body, error status, suppress_tracing, context attributes, TraceConfig(hide_inputs, hide_outputs), uninstrument, and the entry point.

tox run-parallel -e py310-ci-typesafe,py310-ci-typesafe-latest,py314-ci-typesafe,py314-ci-typesafe-latest

All four pass: ruff format, ruff check, mypy clean, 10 tests.

Also verified end to end against a local Phoenix by running the three examples/*.py scripts against the live API — 4 traces, 4 LLM spans, all OK, with the request body, invocation params, token counts, metadata and session link rendering as expected.

Open questions for reviewers

  1. Should typesafe become a well-known llm.provider value? Right now it's a literal in this package and is absent from OpenInferenceLLMProviderValues (Python and TS) and from spec/semantic_conventions.md. Precedent (#3348 for Ollama) says the enum entry lands in the same PR as the instrumentor; I left it out because it's a cross-language spec change and wanted a call on the spelling (typesafe vs typesafe-ai) first. Happy to add it here.
  2. input.value is reconstructed, not captured. get_request_attributes rebuilds the request body from the bound call arguments, and the tests assert it equals what went over the wire. That equality is maintained by a second copy of the SDK's body-building rules, so an SDK change to defaults or the extra_body merge would make the span quietly disagree with the request. Wrapping typesafe_sdk._core.endpoints.prepare_system_one instead would make the captured body a fact rather than a reconstruction — worth doing now, or as a follow-up?
  3. Per-answer confidence and probabilities currently live only inside the output.value JSON. Flattened attributes for them could be a follow-up.
  4. The instrumentor class is TypeSafeAIInstrumentor (vendor's full name, cf. VertexAIInstrumentor) while the distribution is openinference-instrumentation-typesafe. Say the word if you'd rather have TypeSafeInstrumentor.

The SDK has no streaming and no tool calling, so there's no stream proxy.


Generated by Claude Code

@mikeldking

Copy link
Copy Markdown
Contributor

Looks like this was kicked off #3769

@mikeldking
mikeldking force-pushed the claude/adoring-heisenberg-714zx6 branch from e0b9526 to 18041b8 Compare September 18, 2026 05:38
@mikeldking mikeldking changed the title feat(python): add openinference-instrumentation-typesafe-ai feat(python): add openinference-instrumentation-typesafe Sep 18, 2026
@mikeldking
mikeldking marked this pull request as ready for review September 18, 2026 05:40
@mikeldking
mikeldking requested a review from a team as a code owner September 18, 2026 05:40
@github-actions

Copy link
Copy Markdown
Contributor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Adds an instrumentor for the TypeSafe AI Python SDK (`typesafe-sdk`).
`TypeSafeClient.system_one` and `AsyncTypeSafeClient.system_one` are
traced as OpenInference LLM spans.

A System One call is not a chat exchange: it sends a `state` plus a map
of typed `questions` and returns one typed `answer` per question, with
no message list on either side. The span therefore records the request
and response bodies as `input.value` and `output.value` only -- no
`llm.input_messages` / `llm.output_messages`. The `questions` map rides
in `llm.invocation_parameters` as the response schema, and token usage
plus request/response model names are recorded. Attributes are built
with the pure helpers from `openinference.instrumentation`.

The clients are patched through the SDK's public re-export rather than
their private `_core` modules. `python/uv.toml` exempts `typesafe-sdk`
from the rolling `exclude-newer` cutoff, since the minimum supported
version (0.6.0) is newer than that window; it uses the far-future date
because a per-package timestamp overrides the global cutoff and a real
date would become a permanent ceiling.

Includes offline tests (httpx2 MockTransport), runnable examples, and
tox / release-please / README wiring.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zpz5amSUru67UVgWKQtHt
@mikeldking
mikeldking force-pushed the claude/adoring-heisenberg-714zx6 branch from 18041b8 to 871d827 Compare September 18, 2026 05:54
…rive token total only when complete

- Pass an enc_hook to msgspec.to_builtins so MappingProxyType questions and
  tuple state are recorded as JSON in input.value / llm.invocation_parameters
  instead of a repr string; run state through the same conversion.
- Only set llm.token_count.total when both prompt and completion counts are
  present, so a partial usage report is not presented as a total.
- Add tests for abstract-mapping inputs, partial usage, and the SDK usage-guide
  per-call options (model, retry, timeout, extra_headers) plus
  TYPESAFE_DEFAULT_MODEL.

Verified against local Phoenix: before/after run with MappingProxyType
questions shows questions as a JSON object after the fix; all documented
usage patterns produce correct LLM spans.
Comment on lines +94 to +102
invocation_parameters = {k: v for k, v in body.items() if k != "state" and v is not None}
return {
**get_span_kind_attributes(OpenInferenceSpanKindValues.LLM),
**get_input_attributes(body, mime_type=OpenInferenceMimeTypeValues.JSON),
**get_llm_attributes(
provider=LLM_PROVIDER,
request_model_name=model,
invocation_parameters=invocation_parameters,
),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hide_inputs redacts input.value and leaves this llm.invocation_parameters copy of questions on the span. Chat instrumentors get the prompt stripped because it lives on llm.input_messages, which that flag does clear.

That is a masking hole, not a leftover field. TypeSafe instructions are the prompt. They often describe customer tickets. A user who sets hide_inputs=True because state is PII still exports the full questions map, and the README tells them hide_inputs / hide_outputs is enough. test_trace_config_hides_inputs_and_outputs never looks at LLM_INVOCATION_PARAMETERS, so this ships as "masking works."

Update the README masking bullet to TraceConfig(hide_inputs=True, hide_outputs=True, hide_llm_invocation_parameters=True). Change test_trace_config_hides_inputs_and_outputs to instrument with that config and assert SpanAttributes.LLM_INVOCATION_PARAMETERS is absent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5a230f3, though by a different route than suggested, and the PII framing here doesn't hold for this API.

On the premise. A System One call has no input messages — the analogy to chat instrumentors getting the prompt stripped via llm.input_messages doesn't transfer. The customer ticket is the state, and state was already excluded from the invocation parameters (k != "state"), so it was never in the copy that survived hide_inputs. What survived was the developer-authored question schema: instructions plus criteria. Canary run on a local Phoenix, real API call, TraceConfig(hide_inputs=True, hide_outputs=True) under the old code:

input.value=__REDACTED__   output.value=__REDACTED__
PII-CANARY-STATE anywhere:    false     <- caller data was already covered
INSTRUCTIONS-CANARY anywhere: true      <- the question schema was not

On the real problem. Your mechanism was right: hide_inputs doesn't touch LLM_INVOCATION_PARAMETERS (only hide_llm_invocation_parameters does, config.py:346). And it was worse than one flag missing, because questions was recorded twice — in input.value and again in the invocation parameters. hide_inputs cleared the first copy, hide_llm_invocation_parameters the second, so neither flag alone removed the instructions and the split was the actual defect.

What we did instead of documenting a third flag. llm.invocation_parameters is now call configuration only: the model and any extra_body fields. The state and the questions are recorded once, in input.value. So TraceConfig(hide_inputs=True, hide_outputs=True) is the whole masking story for request and response content, with no extra flag for a user to know about:

typesafe-hide-inputs-v2   input.value=__REDACTED__  output.value=__REDACTED__
                          llm.invocation_parameters={"model": "jev-latest"}
                          PII-CANARY-STATE anywhere:    false
                          INSTRUCTIONS-CANARY anywhere: false
                          token_count.total=385

test_trace_config_hides_inputs_and_outputs now asserts both the state and the instructions text are absent under hide_inputs alone, and the README no longer advertises hide_llm_invocation_parameters as part of the masking story, since it no longer covers request content.

The trade-off, for the record: llm.invocation_parameters is now {"model": ...} for calls that pass no extra_body, which is thin in the Phoenix params panel. Happy to revisit if you'd rather see the schema surfaced there — but if so it should be under an attribute that hide_inputs covers, not one it doesn't.

Comment on lines +87 to +93
body: Dict[str, Any] = {
"state": _to_builtins(state),
"model": model,
"questions": _to_builtins(questions),
}
if extra_body:
body.update(extra_body)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state and questions go through _to_builtins. extra_body does not, so Mapping/Sequence values become repr strings on the span while the SDK encodes them as JSON. extra_body={"ids": range(3)} is [0,1,2] on the wire and "range(0, 3)" here.

The PR's contract is that input.value is the request body. Tests assert it equals the recorded HTTP JSON. extra_body is a public SDK field whose values are JSONValue, which includes Mapping and Sequence. Leaving it unconverted means the span reports a string the collector cannot query the same way as the wire, and the MappingProxyType fix you already landed for questions does not apply to the rest of the body.

After body.update(extra_body), set body = _to_builtins(body). Add a test next to test_abstract_mapping_questions_and_tuple_state that input.value equals the recorded request when extra_body holds a MappingProxyType.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 5fdfa2e. extra_body now goes through _to_builtins before the merge, so it gets the same treatment as state and questions.

The mechanism is safe_json_dumps, which is json.dumps(..., default=str) — so an abstract Mapping in extra_body was serialized as its repr, on both input.value and llm.invocation_parameters.

Proven against a local Phoenix with two builds of the same working tree, one call each to the real API, passing extra_body={"state": MappingProxyType({...})} (the SDK documents extra_body as last-write-wins over state):

jq -r '.[0].attributes["input.value"] | fromjson | .state | type'

before (fix reverted): string   -> "{'ticket': {'subject': 'Charged twice for one order', ...}}"
after:                 object   -> {"ticket": {"subject": "Charged twice for one order", ...}}

test_abstract_containers_in_extra_body covers it. One note for whoever reads that test next: the tuple assertion passes either way, since json.dumps already renders tuples as arrays — the MappingProxyType assertion is the one that catches the bug.

… masking flags

extra_body values are JSONValue, so they may hold abstract Mapping /
Sequence containers. They skipped _to_builtins, so a MappingProxyType
landed on input.value and llm.invocation_parameters as its repr while the
SDK encoded it as JSON on the wire. Route extra_body through
_to_builtins, matching state and questions.

The questions map rides in llm.invocation_parameters, so it is masked by
hide_llm_invocation_parameters rather than hide_inputs. Say so in the
README and in the module docstring, and note that state, the caller data,
is never copied into the invocation parameters.

Tests cover abstract containers in extra_body, that hide_inputs keeps the
state off the span while the questions schema survives, and that
hide_llm_invocation_parameters drops the attribute entirely.
The questions map was duplicated into llm.invocation_parameters, where it
played the role response_format plays for chat APIs. But questions carry
the caller's instructions, so that split the request across two
attributes governed by two different masking flags: hide_inputs redacted
the input.value copy and left the invocation-parameters copy, and
hide_llm_invocation_parameters did the reverse.

Keep llm.invocation_parameters to call configuration, meaning the model
and any extra_body fields. The state and the questions are now recorded
once, in input.value, so hide_inputs alone keeps the whole request off
the span.
typesafe-sdk 0.7.0 moved questions, answers, and usage from msgspec
structs to pydantic models. msgspec cannot encode those, so every one of
them fell through to the encoder hook's string fallback and landed on the
span as a repr: input.value carried
"type='noul' instructions='Is this about billing?' criteria=None" where
the wire body has a JSON object, and output.value did the same for the
answers and the usage. This is what the py3{10,14}-ci-typesafe-latest
environments were failing on.

Dump anything exposing model_dump through its own serializer, which is
the one the SDK encodes with, before falling back to materializing
abstract containers. No new dependency: the check is duck-typed, so the
msgspec path still serves typesafe-sdk 0.6.x.
@mikeldking
mikeldking merged commit 13bd908 into main Sep 18, 2026
23 checks passed
@mikeldking
mikeldking deleted the claude/adoring-heisenberg-714zx6 branch September 18, 2026 19:52
@mikeldking mikeldking mentioned this pull request Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants