Field notes · For the engineer · 27 August 2026 · 7 min

OpenAI function calling audit logs — what's missing

Everything the Responses and Chat Completions APIs log about a tool call, everything they don't, and the six fields you have to add yourself before the log is evidence.

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 questionThe provider log hasYou add
What the model acted onThe prompt, for a retention window, at the providerA fingerprint of the input, so the instruction can be matched later without the record holding the data
The call as emittedYes — id, name, argumentsKeep the model's call_id; it is the join key between your record and theirs
Was it checkedNo slotThe policy, in English, and the verdict — recorded BEFORE execution
Who authorisedThe API keyThe approver's identity from the authenticated server session, never from an argument
Was it executed, and what happenedNo — only the tool-result text you sent backThe external reference: transaction id, record id, HTTP status
Has it been alteredUnknowableA 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.

Start

Keep the traces. Add the record.

Two lines in one agent, free while you evaluate — and it reads the spans you already emit.

  • No card to start
  • Nothing is blocked until you say so
  • Your proof stays yours if you leave