Changing an existing OpenAI Python SDK application to an OpenAI-compatible endpoint can look like a three-line migration:

client = OpenAI( api_key=os.environ["CRAZYROUTER_API_KEY"], base_url="https://api.crazyrouter.com/v1", ) 

The first request may even return HTTP 200. That proves the endpoint, credential, and model ID can work together. It does not prove that the rest of the application is compatible.

On November 13, 2026, the same integration surfaces were checked with deepseek-v4-flash, gpt-6.1-terra, and claude-sonnet-5-5. The retained runs exposed differences in temperature validation, structured-output formats, tool errors, streaming deltas, and usage metadata.

AI assisted with drafting and language editing. The observations, screenshots, and code behavior below were checked against retained test runs from November 13, 2026.

This is not a universal capability matrix for those model families. It is a migration method built from one dated set of routes and responses.

Contract 1: Connection Is More Than a Base URL

Start with the smallest possible request. Do not include structured output, tools, streaming, or optional sampling parameters yet.

import os from openai import OpenAI client = OpenAI( api_key=os.environ["CRAZYROUTER_API_KEY"], base_url="https://api.crazyrouter.com/v1", ) model = os.environ.get("OPENAI_MODEL", "deepseek-v4-flash") response = client.chat.completions.create( model=model, messages=[{"role": "user", "content": "Reply with only: connected"}], ) print(response.choices[0].message.content) 

This isolates three variables:

  • the compatible endpoint, including its API version path;
  • the credential issued for that service;
  • the exact API model ID.

Model IDs should be treated as opaque identifiers. Changing deepseek-v4-flash to deepseek-v4_flash is not cosmetic. The server sees a different string.

Once this request succeeds, keep the result as a connection check. Do not reuse it as evidence that every feature in the production call path works.

The official OpenAI Python SDK repository is the reference for current client configuration and streaming APIs. Whether a compatible provider implements the same optional behavior still requires a live test against that provider.

Contract 2: Optional Parameters Belong to Model Profiles

A shared helper often adds defaults such as temperature=0 to every request. That is convenient until a new route applies a different constraint.

In the retained run, deepseek-v4-flash and claude-sonnet-5-5 accepted a temperature of zero. The gpt-6.1-terra route rejected 0, 0.3, and 0.7.

The Terra route rejects temperature 0.7 and identifies the temperature parameter

The error included:

Parameter temperature must be 1 for this model param=temperature code=invalid_parameter_value 

That supports a narrow conclusion: the tested route required a value of one on that date. The response does not identify whether the restriction came from the model, an upstream adapter, or the gateway.

The engineering fix is to move optional arguments out of the universal request builder.

MODEL_PROFILES = { "deepseek-v4-flash": {"temperature": 0}, "gpt-6.1-terra": {"temperature": 1}, "claude-sonnet-5-5": {"temperature": 0}, } response = client.chat.completions.create( model=model, messages=messages, **MODEL_PROFILES.get(model, {}), ) 

For an untested route, omitting an optional field is usually easier to diagnose than sending a guessed cross-model default.

Structured output is part of the same capability contract

The same response_format does not necessarily travel across routes.

One extraction task was sent with response_format={"type": "json_object"}. deepseek-v4-flash returned 200 and JSON that passed json.loads. claude-sonnet-5-5 returned 400 and requested json_schema with an explicit schema.

The same json_object request succeeds on one route and is rejected on another

A follow-up json_schema request produced parseable JSON on both of those routes. The test set did not include json_schema on gpt-6.1-terra, so it should remain unknown in a capability registry rather than being marked true or false.

The application still needs local validation after the API accepts the request:

import json payload = json.loads(response.choices[0].message.content) required = {"name", "age", "occupation"} missing = required - payload.keys() if missing: raise ValueError(f"missing fields: {sorted(missing)}") 

An HTTP success confirms transport and server-side acceptance. It does not prove that the payload satisfies the business contract.

Contract 3: Error Handling Must Read the Whole Error

Tool calling produced a different kind of failure.

deepseek-v4-flash and claude-sonnet-5-5 returned the expected tool_calls. The gpt-6.1-terra route failed twice with HTTP 400 and code=provider_temporarily_unavailable.

The surprising part was param=reasoning_effort. The request did not contain that field.

If the client reacts only to param, it may change code that was never responsible for the failure. A useful diagnostic record should keep these fields together:

  • HTTP status;
  • message, code, and param;
  • the sanitized request body actually sent;
  • model ID and timestamp;
  • retry count and final outcome.

For a temporary provider error, use bounded backoff and then a fallback that has already passed the same capability tests. Before configuring that fallback, compare the compatible endpoint documentation with the current model catalog. A model appearing in a catalog does not guarantee that every account or key can call it.

Retries also need an idempotency boundary. If the model proposes a function that places an order, sends a message, or writes data, retry the model request before executing the side effect. Replaying the entire workflow can perform the action twice.

Contract 4: A Stream Contains More Than Visible Text

Many streaming loops assume every event has choices[0].delta.content. The retained responses broke that assumption in several ways.

deepseek-v4-flash emitted reasoning tokens in reasoning_content while content was null. Its final answer still arrived through content. The gpt-6.1-terra delta exposed refusal, and the choice included content_filter_result. The usage event at the end had an empty choices array.

DeepSeek and Terra expose different streaming fields, including an empty choices usage chunk

The parser should collect usage independently and check for an empty list before indexing.

stream = client.chat.completions.create( model=model, messages=[{"role": "user", "content": "Count to three"}], stream=True, stream_options={"include_usage": True}, ) answer_parts = [] usage = None for chunk in stream: if chunk.usage is not None: usage = chunk.usage if not chunk.choices: continue delta = chunk.choices[0].delta if delta.content: answer_parts.append(delta.content) reasoning = getattr(delta, "reasoning_content", None) if reasoning: # Keep reasoning state separate from the final answer. pass answer = "".join(answer_parts) 

The SDK handles the underlying Server-Sent Events framing. A custom HTTP parser also has to ignore comment lines such as : PING, recognize data: [DONE], and reject or log malformed JSON without crashing the whole response.

Contract 5: Usage Metadata Is Not a Ready-Made Invoice

All three routes returned prompt_tokens, completion_tokens, and total_tokens. Their extra metadata differed:

  • DeepSeek included input and output token details.
  • Terra included latency_checkpoint.
  • Claude included input_tokens, output_tokens, and cache-related fields.

Those numbers may be details or aliases of the standard totals. Adding every numeric field can double-count usage.

A safer implementation uses two layers:

  1. Normalize input, output, and total tokens for application telemetry.
  2. Preserve a sanitized copy of the original usage object for billing reconciliation.

Cost calculation should then use the provider's billing definition, the selected model price, and any cache rules. A few real requests compared with the account ledger are more valuable than a large batch of unverified local arithmetic.

A Rollout Sequence That Keeps Failures Small

Run the migration as a sequence of contracts instead of one large end-to-end test:

  1. Send a minimal non-streaming request.
  2. Add only the model-specific optional parameters used by the application.
  3. Validate structured output locally, including missing and invalid fields.
  4. Exercise streaming with empty choices, final text, and usage enabled.
  5. Test tool arguments, temporary failures, retry limits, and side-effect boundaries.
  6. Compare a small sample of usage records with the provider ledger.

Keep base_url, the credential reference, and model in deploy-time configuration. A rollback that restores only the old URL and key but leaves the compatible service's model ID behind is not a complete rollback.

OpenAI compatibility is valuable because it preserves a familiar client and request shape. It should not be read as a promise that optional parameters, capabilities, streaming events, and accounting fields are identical. Treat the first 200 response as the start of verification, not the end of migration.