Skip to content

Commit a69244e

Browse files
authored
feat: Add Instructor integration (#465)
ref https://python.useinstructor.com/ resolves #460 Users can enable Instructor tracing either globally with auto-instrumentation: ```python import braintrust braintrust.auto_instrument() ``` or explicitly for Instructor only: ```python from braintrust.integrations import InstructorIntegration InstructorIntegration.setup() ``` Manual wrapping is also supported for direct client/class wrapping: ```python from braintrust import wrap_instructor client = wrap_instructor(client) ``` The integration instruments sync and async Instructor structured-output APIs, including `create`, `create_with_completion`, `create_partial`, and `create_iterable`. It emits `task` spans for Instructor structured-output work. Each span captures the response model, mode, messages, max retries, retry count, validation errors, and the extracted Pydantic output (or collected iterable/partial outputs): ```python { "input": { "response_model": "Person", "mode": "TOOLS", "messages": [...], }, "output": {"name": "Grace", "age": 45}, "metadata": { "response_model": "Person", "mode": "TOOLS", "max_retries": 3, "retry_count": 0, "validation_errors": [], }, } ``` Token usage and LLM request/response spans remain owned by the underlying provider integrations such as OpenAI or Anthropic to avoid double-counting.
1 parent f24c111 commit a69244e

20 files changed

Lines changed: 1089 additions & 131 deletions

py/noxfile.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,21 @@ def test_cohere(session, version):
230230
_run_tests(session, f"{INTEGRATION_DIR}/cohere/test_cohere.py", version=version)
231231

232232

233+
INSTRUCTOR_VERSIONS = _get_matrix_versions("instructor")
234+
235+
236+
@nox.session()
237+
@nox.parametrize("version", INSTRUCTOR_VERSIONS, ids=INSTRUCTOR_VERSIONS)
238+
def test_instructor(session, version):
239+
"""Test the native Instructor structured-output integration."""
240+
_install_test_deps(session)
241+
_install_matrix_dep(session, "instructor", version)
242+
# Instructor wraps a provider client; we exercise it against an OpenAI
243+
# client via VCR cassettes recorded against ``api.openai.com``.
244+
_install_matrix_dep(session, "openai", LATEST)
245+
_run_tests(session, f"{INTEGRATION_DIR}/instructor/test_instructor.py", version=version)
246+
247+
233248
OPENAI_VERSIONS = _get_matrix_versions("openai")
234249

235250

py/pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ lint = [
232232
"dspy",
233233
"google-adk",
234234
"google-genai",
235+
"instructor",
235236
"litellm>=1.83.10",
236237
"livekit-agents",
237238
"livekit-plugins-openai",
@@ -378,6 +379,10 @@ latest = "pydantic-ai==1.102.0"
378379
latest = "autoevals==0.2.0"
379380
"0.0.129" = "autoevals==0.0.129"
380381

382+
[tool.braintrust.matrix.instructor]
383+
latest = "instructor==1.15.1"
384+
"1.11.0" = "instructor==1.11.0"
385+
381386
[tool.braintrust.matrix.google-genai]
382387
latest = "google-genai==2.6.0"
383388
"1.75.0" = "google-genai==1.75.0"
@@ -465,6 +470,7 @@ crewai = ["crewai"]
465470
dspy = ["dspy"]
466471
google_genai = ["google-genai"]
467472
huggingface_hub = ["huggingface-hub"]
473+
instructor = ["instructor"]
468474
langchain = ["langchain-core"]
469475
litellm = ["litellm"]
470476
livekit_agents = ["livekit-agents"]

py/src/braintrust/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def is_equal(expected, output):
6868
from .functions.stream import *
6969
from .generated_types import *
7070
from .integrations.anthropic import wrap_anthropic as wrap_anthropic
71+
from .integrations.instructor import wrap_instructor as wrap_instructor
7172
from .integrations.litellm import wrap_litellm as wrap_litellm
7273
from .integrations.openai import wrap_openai as wrap_openai
7374
from .integrations.openrouter import wrap_openrouter as wrap_openrouter

py/src/braintrust/auto.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
DSPyIntegration,
2020
GoogleGenAIIntegration,
2121
HuggingFaceHubIntegration,
22+
InstructorIntegration,
2223
LangChainIntegration,
2324
LiteLLMIntegration,
2425
LiveKitAgentsIntegration,
@@ -57,6 +58,7 @@ def auto_instrument(
5758
litellm: bool = True,
5859
pydantic_ai: bool = True,
5960
google_genai: bool = True,
61+
instructor: bool = True,
6062
openrouter: bool = True,
6163
mistral: bool = True,
6264
huggingface_hub: bool = True,
@@ -90,6 +92,7 @@ def auto_instrument(
9092
litellm: Enable LiteLLM instrumentation (default: True)
9193
pydantic_ai: Enable Pydantic AI instrumentation (default: True)
9294
google_genai: Enable Google GenAI instrumentation (default: True)
95+
instructor: Enable Instructor (structured-output) instrumentation (default: True)
9396
openrouter: Enable OpenRouter instrumentation (default: True)
9497
mistral: Enable Mistral instrumentation (default: True)
9598
huggingface_hub: Enable HuggingFace Hub instrumentation (default: True)
@@ -164,6 +167,8 @@ def auto_instrument(
164167
results["pydantic_ai"] = _instrument_integration(PydanticAIIntegration)
165168
if google_genai:
166169
results["google_genai"] = _instrument_integration(GoogleGenAIIntegration)
170+
if instructor:
171+
results["instructor"] = _instrument_integration(InstructorIntegration)
167172
if openrouter:
168173
results["openrouter"] = _instrument_integration(OpenRouterIntegration)
169174
if mistral:

py/src/braintrust/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .dspy import DSPyIntegration
1010
from .google_genai import GoogleGenAIIntegration
1111
from .huggingface_hub import HuggingFaceHubIntegration
12+
from .instructor import InstructorIntegration
1213
from .langchain import LangChainIntegration
1314
from .litellm import LiteLLMIntegration
1415
from .livekit_agents import LiveKitAgentsIntegration
@@ -34,6 +35,7 @@
3435
"DSPyIntegration",
3536
"GoogleGenAIIntegration",
3637
"HuggingFaceHubIntegration",
38+
"InstructorIntegration",
3739
"LiteLLMIntegration",
3840
"LiveKitAgentsIntegration",
3941
"LangChainIntegration",
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Test auto_instrument for Instructor."""
2+
3+
import instructor
4+
import openai
5+
from braintrust.auto import auto_instrument
6+
from braintrust.integrations.test_utils import autoinstrument_test_context
7+
from pydantic import BaseModel
8+
9+
10+
class Person(BaseModel):
11+
name: str
12+
age: int
13+
14+
15+
# 1. Instrument
16+
results = auto_instrument()
17+
assert results.get("instructor") is True, results
18+
19+
# 2. Idempotent
20+
results2 = auto_instrument()
21+
assert results2.get("instructor") is True
22+
23+
# 3. Drive a real instructor.from_openai call against a recorded cassette and
24+
# verify a parent task-typed Instructor span shows up alongside the OpenAI
25+
# llm child span. Cassette is shared with the in-process test suite under
26+
# integrations/instructor/cassettes/<version>/.
27+
with autoinstrument_test_context(
28+
"TestInstructorOpenAISpans.test_instructor_openai_single_success",
29+
integration="instructor",
30+
) as memory_logger:
31+
client = openai.OpenAI(api_key="sk-test-dummy-api-key-for-vcr-tests")
32+
patched = instructor.from_openai(client, mode=instructor.Mode.TOOLS)
33+
result = patched.chat.completions.create(
34+
model="gpt-4o-mini",
35+
response_model=Person,
36+
max_retries=3,
37+
messages=[{"role": "user", "content": "Extract Grace, age 45."}],
38+
)
39+
assert isinstance(result, Person)
40+
assert result.model_dump() == {"name": "Grace", "age": 45}
41+
42+
raw = memory_logger.pop()
43+
spans = []
44+
for s in raw:
45+
if isinstance(s, list):
46+
spans.extend(s)
47+
else:
48+
spans.append(s)
49+
types = [s["span_attributes"].get("type") for s in spans]
50+
assert "task" in types, f"missing instructor parent (task) span: {types}"
51+
assert "llm" in types, f"missing openai child (llm) span: {types}"
52+
parent = next(s for s in spans if s["span_attributes"].get("type") == "task")
53+
assert parent["span_attributes"]["name"] == "instructor.create"
54+
assert parent["metadata"]["response_model"] == "Person"
55+
assert parent["metadata"]["mode"] == "TOOLS"
56+
assert parent["metadata"]["retry_count"] == 0
57+
58+
print("SUCCESS")
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Braintrust integration for the Instructor structured-output library."""
2+
3+
from typing import Any
4+
5+
from .integration import InstructorIntegration
6+
from .patchers import InstructorPatcher
7+
8+
9+
def wrap_instructor(client: Any) -> Any:
10+
"""Instrument an ``instructor.Instructor`` / ``AsyncInstructor`` client in place.
11+
12+
The Instructor client returned by ``instructor.from_openai(...)`` (and
13+
the other ``from_<provider>`` factories) is mutated to emit a Braintrust
14+
``task``-typed span per ``create``/``create_with_completion``/
15+
``create_partial``/``create_iterable`` call. The underlying provider
16+
client is left untouched — combine with ``wrap_openai`` /
17+
``wrap_anthropic`` / ``auto_instrument`` to also see the LLM child span.
18+
19+
Returns *client* for chaining.
20+
"""
21+
InstructorPatcher.wrap_target(type(client))
22+
return client
23+
24+
25+
__all__ = [
26+
"InstructorIntegration",
27+
"wrap_instructor",
28+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
interactions:
2+
- request:
3+
body: '{"messages":[{"role":"user","content":"Extract Ada, age 30."}],"model":"gpt-4o-mini","tool_choice":{"type":"function","function":{"name":"Person"}},"tools":[{"type":"function","function":{"name":"Person","description":"Correctly extracted `Person` with all the required parameters with correct types","parameters":{"properties":{"name":{"description":"The person''s name","title":"Name","type":"string"},"age":{"description":"The person''s age","title":"Age","type":"integer"}},"required":["age","name"],"type":"object"}}}]}'
4+
headers: {}
5+
method: POST
6+
uri: https://api.openai.com/v1/chat/completions
7+
response:
8+
body:
9+
string: '{"id":"chatcmpl-instructor-ada-1","object":"chat.completion","created":1779735568,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_ada_1","type":"function","function":{"name":"Person","arguments":"{\"name\":\"Ada\"}"}}],"refusal":null,"annotations":[]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":80,"completion_tokens":8,"total_tokens":88},"service_tier":"default","system_fingerprint":"fp_test"}'
10+
headers:
11+
content-type:
12+
- application/json
13+
x-request-id:
14+
- req_instructor_ada_1
15+
status:
16+
code: 200
17+
message: OK
18+
- request:
19+
body: '{"messages":[{"role":"user","content":"Extract Ada, age 30."},{"role":"assistant","tool_calls":[{"id":"call_ada_1","type":"function","function":{"name":"Person","arguments":"{\"name\":\"Ada\"}"}}]},{"role":"tool","tool_call_id":"call_ada_1","name":"Person","content":"Validation Error found:\n1 validation error for Person\nage\n Field required [type=missing, input_value={''name'': ''Ada''}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.12/v/missing\nRecall the function correctly, fix the errors"}],"model":"gpt-4o-mini","tool_choice":{"type":"function","function":{"name":"Person"}},"tools":[{"type":"function","function":{"name":"Person","description":"Correctly extracted `Person` with all the required parameters with correct types","parameters":{"properties":{"name":{"description":"The person''s name","title":"Name","type":"string"},"age":{"description":"The person''s age","title":"Age","type":"integer"}},"required":["age","name"],"type":"object"}}}]}'
20+
headers: {}
21+
method: POST
22+
uri: https://api.openai.com/v1/chat/completions
23+
response:
24+
body:
25+
string: '{"id":"chatcmpl-instructor-ada-2","object":"chat.completion","created":1779735569,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_ada_2","type":"function","function":{"name":"Person","arguments":"{\"name\":\"Ada\",\"age\":30}"}}],"refusal":null,"annotations":[]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":120,"completion_tokens":12,"total_tokens":132},"service_tier":"default","system_fingerprint":"fp_test"}'
26+
headers:
27+
content-type:
28+
- application/json
29+
x-request-id:
30+
- req_instructor_ada_2
31+
status:
32+
code: 200
33+
message: OK
34+
version: 1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
interactions:
2+
- request:
3+
body: '{"messages":[{"role":"user","content":"Extract Grace, age 45."}],"model":"gpt-4o-mini","tool_choice":{"type":"function","function":{"name":"Person"}},"tools":[{"type":"function","function":{"name":"Person","description":"Correctly extracted `Person` with all the required parameters with correct types","parameters":{"properties":{"name":{"description":"The person''s name","title":"Name","type":"string"},"age":{"description":"The person''s age","title":"Age","type":"integer"}},"required":["age","name"],"type":"object"}}}]}'
4+
headers: {}
5+
method: POST
6+
uri: https://api.openai.com/v1/chat/completions
7+
response:
8+
body:
9+
string: '{"id":"chatcmpl-instructor-grace","object":"chat.completion","created":1779735568,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_grace","type":"function","function":{"name":"Person","arguments":"{\"name\":\"Grace\",\"age\":45}"}}],"refusal":null,"annotations":[]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":80,"completion_tokens":12,"total_tokens":92,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"service_tier":"default","system_fingerprint":"fp_test"}'
10+
headers:
11+
content-type:
12+
- application/json
13+
x-request-id:
14+
- req_instructor_grace
15+
status:
16+
code: 200
17+
message: OK
18+
version: 1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
interactions:
2+
- request:
3+
body: '{"messages":[{"role":"user","content":"Extract Grace, age 45."}],"model":"gpt-4o-mini","tool_choice":{"type":"function","function":{"name":"Person"}},"tools":[{"type":"function","function":{"name":"Person","description":"Correctly extracted `Person` with all the required parameters with correct types","parameters":{"properties":{"name":{"description":"The person''s name","title":"Name","type":"string"},"age":{"description":"The person''s age","title":"Age","type":"integer"}},"required":["age","name"],"type":"object"}}}]}'
4+
headers: {}
5+
method: POST
6+
uri: https://api.openai.com/v1/chat/completions
7+
response:
8+
body:
9+
string: '{"id":"chatcmpl-instructor-grace","object":"chat.completion","created":1779735568,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_grace","type":"function","function":{"name":"Person","arguments":"{\"name\":\"Grace\",\"age\":45}"}}],"refusal":null,"annotations":[]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":80,"completion_tokens":12,"total_tokens":92,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"service_tier":"default","system_fingerprint":"fp_test"}'
10+
headers:
11+
content-type:
12+
- application/json
13+
x-request-id:
14+
- req_instructor_grace
15+
status:
16+
code: 200
17+
message: OK
18+
version: 1

0 commit comments

Comments
 (0)