Function calling is the point where a language model stops producing text and starts doing things — and it is the point most teams assume is logged, because the API returns a tidy JSON object for every call. It is logged, in the sense that the object exists. It is not an audit log, in the sense that an auditor, a regulator or a disputing customer would accept. This is the precise inventory: what OpenAI gives you, what it cannot give you, and the six fields you add before the record is evidence.
What the API gives you
With the Responses API, a tool call arrives as an output item:
{
"type": "function_call",
"id": "fc_7c1…",
"call_id": "call_9Xk…",
"name": "wire_transfer",
"arguments": "{\"payee\":\"Acme Ltd\",\"amount\":50000,\"currency\":\"USD\"}"
}Chat Completions returns the same information as message.tool_calls[] with an id, function.name and function.arguments. Either way you get: the model’s own call id, the function name, the arguments as a JSON string, and — from the response envelope — the model, the timestamp, token usage, and the response id. If you have the OpenAI dashboard’s request logging on, the provider keeps the request and response bodies according to its published data-retention policy. Check the endpoint and account setting rather than assuming a window; the provider’s copy is still theirs, not yours.
That is a complete record of what the model emitted. It is worth being precise about what it is not.
What the API cannot know
- Whether the call was executed. The model proposes; your code disposes. OpenAI never learns whether wire_transfer ran, failed, was retried, or was silently dropped. The only trace of execution is the tool-result message you choose to send back — which is text you wrote.
- What the result was in the real world. The bank’s transaction reference, the CRM record id, the HTTP status — none of it exists at the provider.
- Who authorised it. The API key is the actor. A named person, if there was one, is somewhere in your session layer and nowhere in the log.
- Whether anything checked it first. There is no slot for a policy verdict, because there is no policy. The model’s output is the last thing the provider sees before your code acts.
- Whether the record has been altered since. Whatever you persisted is a row in your database. Nothing about it would show an edit.
- Anything after the retention window. The audit period is twelve months; the provider’s log is not.
The six fields you add
The fix is not a different provider; every model API has the same shape, because every model API is a text-in, text-out service that cannot see your side of the call. The fix is six fields you record yourself, in order, at the point where the tool call crosses from proposal to action:
auditant.record(
call_id="call_9Xk…", # the model's own id — keep it
name="wire_transfer",
arguments_fingerprint=sha256(args), # not the payload
input_fingerprint=sha256(prompt), # what the model acted on
policy="above $10,000 a person signs first",
verdict="waiting", # BEFORE execution
approver=session.user, # from the server session
result_ref="TXN-88213", # the external system's id
)| The question | The provider log has | You add |
|---|---|---|
| What the model acted on | The prompt, for a retention window, at the provider | A fingerprint of the input, so the instruction can be matched later without the record holding the data |
| The call as emitted | Yes — id, name, arguments | Keep the model's call_id; it is the join key between your record and theirs |
| Was it checked | No slot | The policy, in English, and the verdict — recorded BEFORE execution |
| Who authorised | The API key | The approver's identity from the authenticated server session, never from an argument |
| Was it executed, and what happened | No — only the tool-result text you sent back | The external reference: transaction id, record id, HTTP status |
| Has it been altered | Unknowable | A hash of the previous entry on every entry, and an independently timestamped seal on the head |
Three mistakes that look like logging
Logging the tool result as proof of execution.The result message is what you told the model happened. Log the external system’s own reference, or you have logged a claim.
Putting the approver in the arguments. An approved_by field the model fills in is the model asserting who approved it. Take the identity from the session on the server, after the model has emitted the call and before your code runs it.
Storing full payloads “for completeness”. Now the audit log contains every customer’s data and is itself a compliance problem. Fingerprints match; they do not leak.
If you already trace
The OpenInference instrumentors for the OpenAI SDK emit all of the provider-side fields as OpenTelemetry spans. A system of record can read those spans and add the six fields downstream, so the instrumentation you did for LangSmith or Phoenix is the instrumentation for this too. The distinction between a trace and an audit trail is the subject of the previous note; the full field list is at /ai-agent-audit-trail.
See it on a real chain
The evidence path described above is running on our own account. Every ad-account change made by the agents behind Admiral, our paid-ads product, is recorded on the chain admiral/prod — actions on the chain, seals countersigned, and the sealed-through sequence are read live at /trust, not written into the page.