Skip to content

ChatCompletionMessageToolCallParam "cannot instantiate typing.Union"

Nothing left your machine. This is a TypeError raised by CPython inside your own process while you were assembling arguments — there is no HTTP status, no request id, no tokens billed, and no model involved. So it is not an authentication problem, not a quota or rate limit, not a malformed request, and not the API changing under you. It is a usage error against the client library, and the reframe is most of the fix: the name you called is a type annotation, not a constructor.

The message is literally about typing, not about OpenAI

cannot instantiate typing.Union is what the interpreter says when you put parentheses after a union alias — Union[A, B](...). A union is a description of which types are acceptable somewhere. It is not a class, it has no constructor, and calling it is meaningless, so the interpreter refuses at the moment the call executes.

Two facts have to collide for this to reach you:

  • A TypedDict is callable. Calling one returns a plain dictionary. That is why writing ChatCompletionMessageToolCallParam(id=..., ...) ever appeared to work: you were not constructing a typed object, you were building a dict by a more expensive route.
  • A union of TypedDicts is not callable. The moment the symbol stops naming one alternative and starts naming a choice between several, the call site that was quietly fine becomes a hard error.

Your import still succeeds. Your annotations still type-check. Only the call breaks — which is why the failure lands at runtime, in a line nobody had reason to suspect.

The *Param names were never meant to be called

This is the judgement worth taking away, and it generalizes past this one symbol.

In this SDK, the *Param family describes inputs: what you are permitted to pass in. They exist so a type checker can verify the dictionary you hand to a method. At runtime they are erased into ordinary dictionaries. The supported way to build one has always been a dict literal, with the *Param name used as the annotation on the variable:

tool_call: ChatCompletionMessageToolCallParam = {
    "id": call_id,
    "type": "function",
    "function": {"name": name, "arguments": arguments_json},
}

Code that calls the name instead has been relying on an incidental property of TypedDict rather than on anything the library promised. That is why an SDK upgrade can break you with no deprecation warning and no API change: from the library’s perspective, the supported usage — annotate a dict — kept working perfectly. Widening an input type to a union is a compatible change for annotations and a breaking change only for a pattern that was never part of the contract.

The reason the widening happens is worth knowing too. A union appears where the wire format gained a second possible shape for the same slot. The library is modelling something the API can now express; it is not churning for its own sake. That matters when you decide what to do next.

Is retrying useful?

No, and the reason is more useful than the verdict: there is nothing to retry. No request was ever built.

The SDK’s retry policy — twice by default with a short exponential backoff, covering connection errors, 408, 409, 429 and 5xx responses — operates on HTTP round trips. This exception is raised while you are constructing an argument, one or more frames above any transport code. Setting max_retries higher changes nothing, because the retry machinery never gets a chance to run. Re-executing the same line runs the same bytecode against the same imported symbol and raises the same TypeError at the same instant.

If you have wrapped this call in a retry decorator, that decorator is currently converting an instant, clearly-attributed crash into a slow one. Exclude TypeError from it — retrying a programming error only delays the traceback that tells you where the bug is.

Three checks that confirm it is local

Read the last frame of the traceback. It sits in your code, or in a framework you call, not in the SDK’s HTTP client. The exception type is TypeError, not a subclass of the library’s API error hierarchy. That alone settles the category.

Look for a request id. Failed API calls carry an x-request-id header, which the SDK attaches to its error objects — request_id in Python, requestID in Node. There is no request id here, because there is no response here. An error you cannot get a request id out of is not an error the vendor can look up, and that is a signal about where the fault lives rather than a limitation.

Print the symbol you imported. Do it in the same interpreter and the same virtual environment as the failing code, not in a fresh shell with different packages installed. If its representation shows a choice between alternatives rather than a single type, you have your answer directly, and you also learn what the acceptable shapes now are — which is the information you need for the fix.

Fix by what you were trying to build

  • Constructing a tool call to send — write a dict literal and annotate it. Take the field names from the alternative you actually mean, which you just read off the union.
  • Echoing back a tool call the model returned — you are already holding an SDK response object, not a param type. Serialize that object into a dictionary and append it, rather than reconstructing one field by field. Round-tripping through your own constructor is how a field you did not know about gets silently dropped.
  • You wanted runtime validation — a union alias cannot validate by being called; that was never what it did. Validate the dictionary at the boundary where it enters your system, or use the response model classes, which are real classes, for the direction where the SDK hands you objects.
  • A library is making the call, not you — the traceback names it. The fix belongs in that library, and the local workaround is to stop passing your history through the code path that reconstructs param objects.
  • You are copying a snippet and the import fails instead — symbol locations in a client library move between releases. Take the import path from the package installed in your environment rather than from any write-up, including this one, which deliberately does not print one.

The fix that works and re-arms itself

Pinning back to the release where the call still worked will clear the error today. It is also the option most likely to bring you back here with a harder problem.

The union exists because a second shape is possible. Pinning to a version that models only the first shape means your code cannot represent the second one, and the failure that eventually surfaces is not a clean TypeError at a construction site — it is a response your parsing does not recognize, discovered somewhere far from the line that caused it. If you must pin to ship today, pin with a note saying what to revisit, and treat the call sites as the actual work.

How to confirm it is fixed

Run the line that failed, and watch what it produces: the value must be an ordinary dictionary. If your code still calls a name with parentheses anywhere in the message-building path, you have moved the bug rather than fixed it.

Then exercise a full round trip rather than the one line. Send a prompt that makes the model call a tool, take the response, append the assistant turn, append the result, and send the follow-up request. This is the sequence where a dropped or renamed field shows up, and a single successful construction proves nothing about it.

Last, treat the instance as a class of bug. Search the codebase for call sites of the form SomethingParam( — every one of them is the same latent failure waiting for the next time the library widens a type. Converting them all now costs less than diagnosing the next one, because the next one will arrive on an upgrade you did for an unrelated reason.

The sibling in this cluster is a genuine API rejection rather than a local one: 'tool_calls' cannot be used when 'functions' are present is a 400 from the server, it has a request id, and the conflicting fields come from two different layers of your stack.

If the symbol does not even import — cannot import name TypeAliasType from typing_extensions — you are one layer further down, in the typing backport rather than in the SDK, and the fix is an environment fix rather than a call-site fix.

When you cannot yet tell whether a failure is local, transport, or server-side, the AI coding error triage tool sorts by the same signals used above: whether a status code exists, whether a request id exists, and whether anything was retried before you saw it.