Pull the three provider envelopes side by side and the matrix gets short fast. Most differences are cosmetic; a few are real.
| Concept | OpenAI | Anthropic | Gemini |
|---|---|---|---|
| Tool list field | tools | tools | tools (in config) |
| Schema field | parameters | input_schema | parameters (Schema object) |
| Args type back | JSON string | dict | dict |
| Tool choice | "auto"|"required"|spec | type: "auto"|"any"|"tool" | FunctionCallingMode enum |
| Stop signal | response.completed + output[].type | stop_reason | presence/absence of function_call part |
| Streaming tool args | Deltas | Deltas (per content block) | All-at-once |
| Pre-built native tools | web_search, code_interpreter, file_search | computer_use, text_editor, bash | code_execution, google_search |
| MCP integration | via Apps SDK | via mcp_servers in Messages API | via Vertex AI tool config |
The four cells where the difference is real, not cosmetic, are: the args type (parse vs use), the streaming behavior (deltas vs all-at-once), the pre-built tools (each provider has unique offerings), and the MCP integration shape. Build a thin translation layer for the cosmetic ones and accept that the real ones require provider-specific code.
The temptation is to write a giant abstraction that pretends all three are the same. Resist it for as long as you can. The shape of "send messages and tools, get tool calls back, send results in" is already a small abstraction; over-abstracting hides the real differences and produces SDK-like layers nobody else wants to maintain.