LLM Provider Quirks: Build Compatibility Tests Before You Trust Model Abstraction Developers building multi-model AI applications must create dedicated compatibility test suites for LLM provider quirks, as abstraction layers that work in demos frequently break on provider swaps due to differences in tool calls, structured outputs, streaming, and safety handling. Microsoft 365 Copilot now uses models from Microsoft, Anthropic, and OpenAI, while OpenAI, Anthropic, and Google ship increasingly divergent tool-use and agent APIs, making explicit, testable contracts essential for safe model diversity. Every multi-model AI app eventually reaches the same uncomfortable moment: the abstraction layer works in the demo, then breaks on the first serious provider swap. The failure is rarely dramatic at first. A tool call arrives in a slightly different shape. A schema that passed with one model becomes too strict for another. A streaming parser assumes the wrong event order. A retry turns a harmless timeout into a duplicate write. The dashboard still says the provider returned a successful response, but the product quietly does the wrong thing. This is why LLM provider quirks deserve their own test suite. You need compatibility tests that prove your application contract survives the weird parts of each provider. That matters more now because the market is moving toward model diversity. Microsoft documentation says Microsoft 365 Copilot can use models from Microsoft, Anthropic, and OpenAI depending on the feature and need. Recent reporting also points to Microsoft expanding its in-house MAI model usage to improve cost efficiency and reduce dependence on external frontier labs. At the same time, OpenAI, Anthropic, and Google continue to ship richer tool use, structured output, and agent APIs with different assumptions baked into each surface. The practical lesson for developers is simple: model abstraction is useful, but only after you know what you are abstracting. The easy version of model abstraction looks like this: js const answer = await llm.generate { model: "some-model", prompt: userPrompt} ; That wrapper is fine for plain text. It becomes fragile when your app depends on tool calls, structured outputs, streaming, safety refusals, caching, files, images, function schemas, or provider-specific reasoning controls. Developers often discover this after they have already promised portability. The team starts with one provider. Finance asks for a cheaper model on high-volume tasks. Security asks for a model that keeps data in a specific environment. Product wants a long-context model for large documents. Leadership sees Microsoft-style model choice and asks, “Can we do that too?” The answer is yes, but the hidden work is not the adapter. The hidden work is the contract. A good LLM abstraction does not hide provider differences. It makes the differences explicit, testable, and safe to route around. OpenAI’s function calling guide describes tool calling as a multi-step loop where the model requests a tool, your application executes it, and you send the result back for a final response. Anthropic’s Claude docs describe a similar client-tool loop, but with Claude-specific content blocks, stop reasons, and tool-result handling. Google’s Gemini function calling docs frame the model as returning function arguments for external actions, knowledge augmentation, and capability extension. These are close enough to sound interchangeable and different enough to break code that assumes they are identical. A provider quirk is not a bug. It is a behavior your app must understand before it can depend on a model safely. Some quirks are documented API differences. Some are model behavior patterns. Some only show up under load, long context, unusual schemas, partial streams, or safety-sensitive prompts. You do not need to memorize every provider detail. You need to identify the categories that can break your product. One provider may return tool calls as separate response items. Another may return content blocks. A wrapper may normalize the happy path but leak provider-specific fields during parallel tool calls or partial failures. Your app should not assume that “tool call” means one universal object. Structured output sounds simple: give the model a JSON Schema and receive valid JSON. In practice, provider support differs by API, model, schema keyword, strictness mode, SDK helper, and whether the schema is used for a final answer or a tool call. OpenAI’s structured output docs distinguish schema adherence from older JSON mode and recommend Structured Outputs when available. Anthropic’s structured output docs use an output config.format pattern with schema limitations. Google's Gemini docs describe JSON Schema support for structured outputs, with separate API modes. The words look similar. The integration details are not the same. Streaming is where many abstractions start to leak. You may receive text deltas, reasoning metadata, tool-call deltas, content block starts, content block stops, usage events, safety events, or provider-specific keepalive messages. If your parser expects one event order, a provider swap can turn a smooth UI into missing tokens or broken tool arguments. An invalid schema, blocked prompt, rate limit, overloaded model, timeout, content filter, or provider-side incident should not all become new Error "LLM failed" . Your retry logic needs to know whether to retry, fall back, ask the user for different input, page an engineer, or stop because the action might be unsafe to repeat. Refusals are product behavior, not just model behavior. If one provider returns a structured refusal field and another returns refusal text, your app still needs one user-facing policy. Otherwise the same unsafe request may produce inconsistent UI states, logs, and analytics. Some models expose reasoning effort, caching, priority inference, batch options, or task-specific modes. Others expose different levers or none at all. If your abstraction only accepts temperature and max tokens, it may erase the exact controls that made a provider worth using. Provider compatibility should be tested as a release workflow, not guessed during a migration. Do not start by wrapping every SDK behind the smallest common denominator. That approach turns into a pile of exceptions. Start by writing the contract your product needs. The contract should describe what your app expects from any model, regardless of provider. Then each provider adapter must prove it can satisfy that contract for the use cases you plan to route to it. A practical contract includes: The most important part is the error contract. Many teams test output quality but forget operational behavior. That is backwards. A slightly weaker answer is annoying. A retried payment, deleted file, or invisible refusal is a production incident. Your compatibility suite should be small enough to run in CI and realistic enough to catch the failures that matter. It is not a replacement for deep model evals. It is the preflight check that says, “This provider can safely enter this route.” Pick the workflows your product actually runs: extract invoice fields, draft a pull request summary, classify support tickets, call a CRM tool, answer from documents, generate a UI component, or route an agent step. For each workflow, save a few golden prompts with expected behavior. Do not only include clean examples. Include messy inputs, missing fields, ambiguous user requests, long context, policy-sensitive requests, and cases where the model should ask a question instead of acting. Create schemas that represent your real payloads, then add edge cases. Test required fields, nested objects, arrays, enums, nullable values, descriptions, unknown properties, and large schemas. If you use Zod, Pydantic, or generated JSON Schema, test the generated schema exactly as your app sends it. Your goal is not to prove that every model supports every schema. Your goal is to classify where each provider is safe. Tool calling is a loop, so test the loop. The first model response is not enough. Verify that your adapter can: Use fake tools in CI. For example, a fake calendar tool can record whether a meeting would have been created without touching a real calendar. That lets you test dangerous paths without danger. Record streaming event samples from each provider and replay them through your parser. Include normal text, long output, tool calls, interrupted streams, provider errors, and user cancellation. If the UI depends on streaming, your compatibility suite should fail when an adapter drops tokens, emits duplicate chunks, or marks a response complete too early. Mock the ugly cases: rate limit, invalid request, bad schema, content policy refusal, empty response, network timeout, slow stream, malformed tool arguments, and provider outage. Then assert the product behavior. The answer should not be “log and move on.” It should be route-specific. The following sketch is not a full framework. It shows the shape of a contract-first adapter. Each provider can keep its native SDK internally, but the rest of your product sees one tested result shape. type LlmRequest = { route: "ticket triage" | "invoice extract" | "agent step"; messages: Array<{ role: "system" | "user" | "assistant"; content: string } ; tools?: ToolDefinition ; responseSchema?: unknown; idempotencyKey: string;}; type LlmResult = | { type: "final"; text: string; usage: Usage; providerMeta: unknown } | { type: "tool call"; calls: ToolCall ; usage: Usage; providerMeta: unknown } | { type: "refusal"; reason: string; usage: Usage; providerMeta: unknown }; type LlmFailure = | { type: "retryable"; reason: string; retryAfterMs?: number } | { type: "bad request"; reason: string } | { type: "policy"; reason: string } | { type: "unsafe retry"; reason: string }; interface ProviderAdapter { name: string; supports route: LlmRequest "route" : boolean; generate request: LlmRequest : Promise