Repository navigation
feat(python): add openinference-instrumentation-typesafe - #3773
Conversation
|
Looks like this was kicked off #3769 |
e0b9526 to
18041b8
Compare
Code reviewNo 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
18041b8 to
871d827
Compare
…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.
| 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, | ||
| ), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| body: Dict[str, Any] = { | ||
| "state": _to_builtins(state), | ||
| "model": model, | ||
| "questions": _to_builtins(questions), | ||
| } | ||
| if extra_body: | ||
| body.update(extra_body) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Adds
openinference-instrumentation-typesafe, an instrumentor for the TypeSafe AI Python SDK (typesafe-sdk >= 0.6.0).What it traces
TypeSafeClient.system_oneandAsyncTypeSafeClient.system_onebecome OpenInference LLM spans.A System One call isn't a chat exchange. You send a
stateplus a map of typedquestions(Noul, Choice, Score) and get back one typedanswerper question — there's no message list on either side. So the span records the request and response bodies asinput.valueandoutput.valueand stops there; nollm.input_messages/llm.output_messages. Thequestionsmap rides inllm.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_nameandllm.response.model_name(e.g.jev-latest→jev-1.13.0), andllm.token_count.{prompt,completion,total}.Everything is built from the shared helpers in
openinference.instrumentation, and spans go throughOITracer— soTraceConfigmasking, context attributes (session, user, metadata, tags) andsuppress_tracingall work. Errors record the exception and setERRORstatus.Notable choices
typesafe_sdk.TypeSafeClient/typesafe_sdk.AsyncTypeSafeClientrather than thetypesafe_sdk._core.client.*modules that define them.wraptpatches the same class object either way, and the private path carries no compatibility promise.python/uv.tomlgets atypesafe-sdkexemption. The instrumentor's floor, 0.6.0, was published more recently than CI's rolling 3-dayexclude-newercutoff, 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 thetypesafe-latestcanary into a no-op.httpx2, which vcrpy doesn't patch, so the tests inject anhttpx2.MockTransportthat 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.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/*.pyscripts against the live API — 4 traces, 4 LLM spans, allOK, with the request body, invocation params, token counts, metadata and session link rendering as expected.Open questions for reviewers
typesafebecome a well-knownllm.providervalue? Right now it's a literal in this package and is absent fromOpenInferenceLLMProviderValues(Python and TS) and fromspec/semantic_conventions.md. Precedent (#3348for 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 (typesafevstypesafe-ai) first. Happy to add it here.input.valueis reconstructed, not captured.get_request_attributesrebuilds 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 theextra_bodymerge would make the span quietly disagree with the request. Wrappingtypesafe_sdk._core.endpoints.prepare_system_oneinstead would make the captured body a fact rather than a reconstruction — worth doing now, or as a follow-up?confidenceandprobabilitiescurrently live only inside theoutput.valueJSON. Flattened attributes for them could be a follow-up.TypeSafeAIInstrumentor(vendor's full name, cf.VertexAIInstrumentor) while the distribution isopeninference-instrumentation-typesafe. Say the word if you'd rather haveTypeSafeInstrumentor.The SDK has no streaming and no tool calling, so there's no stream proxy.
Generated by Claude Code