OpenAI
Wrap the client once, where you construct it. Every call site stays exactly as it is.
import metergraphfrom openai import OpenAI
client = metergraph.wrap(OpenAI())
response = client.chat.completions.create( model="gpt-5.6-luna", messages=[{"role": "user", "content": "Summarize this invoice."}],)import * as mg from "metergraph";import OpenAI from "openai";
const client = mg.wrap(new OpenAI());
const response = await 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.
What gets instrumented
Section titled “What gets instrumented”wrap() walks a fixed table of methods. A method not on this list produces no
row.
| Method | Recorded as |
|---|---|
chat.completions.create | chat.completions |
chat.completions.parse | chat.completions.parse |
beta.chat.completions.parse | chat.completions.parse |
responses.create | responses |
responses.stream | responses.stream |
responses.parse | responses.parse |
beta.responses.create | responses |
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.
import metergraphfrom 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 Node client is already promise-based. There is no separate// async client, and nothing extra to configure.const client = mg.wrap(new OpenAI());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
Section titled “Streaming”Streaming works through the same wrap(). The row is finalized when the
stream ends, is cancelled, or raises.
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="")const stream = await client.chat.completions.create({ model: "gpt-5.6-luna", messages: [{ role: "user", content: "Write a release note." }], stream: true,});for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta?.content ?? "");}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.
The usage chunk
Section titled “The usage chunk”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 use
Section titled “Tool use”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.
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))await mg.trace("support-agent", async () => { const messages = [{ role: "user", content: question }]; for (;;) { const response = await client.chat.completions.create({ model: "gpt-5.6-luna", messages, tools, }); const message = response.choices[0].message; if (!message.tool_calls?.length) return message.content; messages.push(message, ...runTools(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.
Batch API
Section titled “Batch API”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.
client = metergraph.wrap(OpenAI())
batch = client.batches.retrieve(batch_id) # no rowoutput = client.files.content(batch.output_file_id) # one row per linefor line in output.text.splitlines(): handle(json.loads(line))const client = mg.wrap(new OpenAI());
const batch = await client.batches.retrieve(batchId); // no rowconst output = await client.files.content(batch.output_file_id!);for (const line of (await output.text()).split("\n")) { // rows here if (line.trim()) handle(JSON.parse(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 execution
Section titled “Batch-first execution”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.
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)const outcome = await mg.batchFirst( client, // not wrapped: batchFirst drives it directly "openai", { model: "gpt-5-mini", input: "Explain batching in one sentence." }, { deadlineMs: 10 * 60 * 1000, acceptDuplicateProviderExecution: true },);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.
Through a gateway
Section titled “Through a gateway”An OpenAI client pointed at another host is still an OpenAI client. Two hosts get extra handling:
https://openrouter.aiis detected automatically. See OpenRouter.https://ai-gateway.vercel.shis detected automatically in Python. See Vercel AI Gateway.
- Name a route to group calls by product surface
- Attribution across threads if you dispatch calls to a pool
- Serverless and short-lived jobs before deploying to a runtime that freezes
- Runnable examples: Python, Node