Skip to content

OpenAI

Wrap the client once, where you construct it. Every call site stays exactly as it is.

app/llm.py
import metergraph
from openai import OpenAI
client = metergraph.wrap(OpenAI())
response = client.chat.completions.create(
model="gpt-5.6-luna",
messages=[{"role": "user", "content": "Summarize this invoice."}],
)

wrap() patches methods on the client you hand it and returns that same object. It does not create a copy, so the variable you assign it to and the original both point at the instrumented client. Wrapping the same client twice is a no-op.

wrap() walks a fixed table of methods. A method not on this list produces no row.

MethodRecorded as
chat.completions.createchat.completions
chat.completions.parsechat.completions.parse
beta.chat.completions.parsechat.completions.parse
responses.createresponses
responses.streamresponses.stream
responses.parseresponses.parse
beta.responses.createresponses

Plus two Batch API readers, covered below.

beta.chat.completions.parse exists only on openai v4. On v5 and later it resolves to nothing and is skipped silently, which is why the entry is harmless to keep. beta.responses has no .parse method on current openai releases, so there is no seam for one.

The async client exposes the same attribute paths, so it needs no special handling. Wrap it the same way.

app/llm.py
import metergraph
from openai import AsyncOpenAI
client = metergraph.wrap(AsyncOpenAI())
async def summarize(text: str) -> str:
response = await client.chat.completions.create(
model="gpt-5.6-luna",
messages=[{"role": "user", "content": text}],
)
return response.choices[0].message.content

The wrapper preserves the provider’s own promise type rather than replacing it with a plain promise, so helpers like .withResponse() and .asResponse() keep working. Capture runs at most once per call, whichever way you consume the result.

Streaming works through the same wrap(). The row is finalized when the stream ends, is cancelled, or raises.

app/llm.py
stream = client.chat.completions.create(
model="gpt-5.6-luna",
messages=[{"role": "user", "content": "Write a release note."}],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")

Time to first token is measured from the first chunk that carries real output: text, reasoning, or the start of a tool call. Usage-only and metadata-only chunks do not count.

OpenAI reports no token counts on a streamed chat completion unless the request asks for them. So on a streaming chat.completions call, when you have not set stream_options yourself, Metergraph adds stream_options: {"include_usage": true} to the request and then hides the resulting usage-only final chunk from your loop. You get the token counts and your iteration looks unchanged.

Set METERGRAPH_PATCH_STREAM_USAGE=0 to leave your requests untouched. The cost of doing that is that streamed chat completions arrive with no usage, so those rows carry no token counts.

Tool calls need no extra configuration. Each request to the API is one row, so a multi-step tool loop produces one row per model call. Group them with trace() so they read as a single workflow.

app/agent.py
with metergraph.trace("support-agent"):
messages = [{"role": "user", "content": question}]
while True:
response = client.chat.completions.create(
model="gpt-5.6-luna",
messages=messages,
tools=tools,
)
message = response.choices[0].message
if not message.tool_calls:
return message.content
messages.append(message)
messages.extend(run_tools(message.tool_calls))

Each row records the tool names offered on the request, and the tool calls and tool results present in the conversation, normalized to call ID, name, arguments and result. See Captured fields for the exact shape, and Content and privacy for how to keep arguments out of capture.

Submitting a batch produces no row. Metergraph captures at the point where you read the results, because that is the first moment the token counts exist. wrap() patches files.content and files.retrieve_content (Python) or files.content and files.retrieveContent (TypeScript), parses the JSONL output, and emits one row per completed item.

app/batch.py
client = metergraph.wrap(OpenAI())
batch = client.batches.retrieve(batch_id) # no row
output = client.files.content(batch.output_file_id) # one row per line
for line in output.text.splitlines():
handle(json.loads(line))

Those rows are recorded as batch.chat.completions or batch.responses depending on the item body, and carry service_tier: "batch" plus the batch custom ID. Items are deduplicated, so reading the same output file twice does not double-count.

The moment of capture differs slightly by language. Python reads the content off the object files.content() returns, so the rows exist as soon as that call comes back. TypeScript defers until you consume the body, so the rows appear when you call .text(), .arrayBuffer() or .blob().

batch_first() is a separate, explicitly opt-in execution API, not a capture feature. It submits one request through the Batch API, waits a deadline you choose, and falls back to a single direct call if the batch has not finished.

app/batch_first.py
outcome = metergraph.batch_first(
client, # not wrapped: batch_first drives it directly
"openai",
{"model": "gpt-5-mini", "input": "Explain batching in one sentence."},
deadline_seconds=10 * 60,
accept_duplicate_provider_execution=True,
)
print(outcome.source) # "batch" or "direct"
print(outcome.metadata.batch_outcome)

Streaming requests are ineligible and raise before any provider call. The batch-first request itself is not captured and never reaches Metergraph: the result source and execution metadata are returned to your code instead. The runnable version is the Python batch-first example.

An OpenAI client pointed at another host is still an OpenAI client. Two hosts get extra handling:

  • https://openrouter.ai is detected automatically. See OpenRouter.
  • https://ai-gateway.vercel.sh is detected automatically in Python. See Vercel AI Gateway.