Testing Whether an Endpoint Is Really OpenAI-Compatible
10 min read · updated August 11, 2026
A compatibility test that asserts “the request returned 200” tells you almost nothing, because a server that ignores every optional parameter also returns 200. The test has to check theeffect of each parameter, which means each probe needs a response property that could only hold if the parameter was applied.
Three outcomes, not two
Design the report around three states per feature, because collapsing them loses the only distinction you care about.
- Implemented — the request succeeded and the response shows the parameter took effect.
- Rejected — the server returned an error. This is the good failure. You know where you stand, and your code can branch on it at runtime.
- Ignored — the request succeeded and the response shows no evidence the parameter was applied. This is the state the whole exercise exists to find, and it is invisible to any test that only checks status codes.
A fourth state is worth recording separately: inconclusive. Some probes cannot give a definite answer from a single call — a model that legitimately chose not to emit a tool call, for instance — and a report that reports a guess as a fact is worse than one that admits the gap. Keep the raw response for every inconclusive probe so a human can look.
The probes worth running
Each of these pairs a request with a property that is only true if the server did the work. Keep max_tokens small everywhere; you are testing plumbing, not generation.
- Envelope. A minimal non-streaming call. Assert
objectequalschat.completion, thatcreatedis a number, thatchoices[0].message.contentis a string and thatchoices[0].finish_reasonis present. - Usage. The same response has a
usageobject with all three counters, each a positive integer. Absent or zero counts mean cost tracking will not work. - Model listing.
GET /modelsreturns an object with adataarray containing the model you just used. - Streaming framing. A streamed call. Assert the content type is
text/event-stream, that every chunk hasobjectequal tochat.completion.chunk, thatfinish_reasonis non-null on exactly one chunk, and that the final line is literallydata: [DONE]. - Streaming usage. The same call with
stream_optionsasking for usage. Assert a chunk arrives with an emptychoicesarray and a populatedusage. - Stop sequences. Ask for a count from one to ten with
stopset to the string for five. Assert the output does not contain it. This also reveals whether the matched sequence is stripped. - Seed. Two identical requests, same
seed, temperature above zero, enough tokens to diverge. Identical outputs suggest it is honoured; differing outputs mean it is not. Note this probe is one-sided — identical outputs can happen by chance on a short generation, so make the generation long enough that coincidence is implausible. - Multiple completions.
nset to three; assertchoices.lengthis three. - Logprobs. Request them and assert the returned structure is populated, not merely present.
- Tools. A single unambiguous tool and a prompt that requires it, with
tool_choiceset to require a call. Asserttool_callsexists, has anidand a functionname, and that itsargumentsstring parses as JSON. - Structured output. A schema with a required property whose value is a two-member enum. Assert the parsed output has the property and its value is in the enum.
- Error shape. Deliberately send an invalid value. Assert a 4xx, and record whether the body has the OpenAI
error.message/error.type/error.paramshape described in the error shape mapping page.
The script
- Install the official client. The point of using the real SDK rather than raw HTTP is that SDK deserialisation is itself part of the test: an envelope that fails to parse is a compatibility failure and you want it to surface as one.
pip install openai
- Save the script below as
probe.py. It takes a base URL, a key and a model name, and prints one line per probe.import json, os, sys, httpx from openai import OpenAI, APIStatusError BASE = sys.argv[1] MODEL = sys.argv[2] KEY = os.environ.get("PROBE_API_KEY", "not-needed") client = OpenAI(base_url=BASE, api_key=KEY, timeout=60.0) results = [] def record(name, state, detail=""): results.append((name, state, detail)) def probe(name, fn): try: record(name, *fn()) except APIStatusError as e: record(name, "REJECTED", "HTTP " + str(e.status_code)) except Exception as e: record(name, "ERROR", type(e).__name__ + ": " + str(e)[:80]) def envelope(): r = client.chat.completions.create( model=MODEL, max_tokens=16, messages=[{"role": "user", "content": "Say hi."}]) ok = (r.object == "chat.completion" and isinstance(r.created, int) and r.choices[0].finish_reason is not None) return ("IMPLEMENTED" if ok else "IGNORED", "object=" + str(r.object)) def usage(): r = client.chat.completions.create( model=MODEL, max_tokens=16, messages=[{"role": "user", "content": "Say hi."}]) u = r.usage ok = u and u.prompt_tokens > 0 and u.completion_tokens > 0 return ("IMPLEMENTED" if ok else "IGNORED", str(u)) def streaming(): # Raw HTTP here on purpose: the SDK hides the sentinel, and the # sentinel is one of the things under test. body = {"model": MODEL, "max_tokens": 24, "stream": True, "messages": [{"role": "user", "content": "Count to five."}]} saw_done, kinds, finishes = False, set(), 0 with httpx.stream("POST", BASE.rstrip("/") + "/chat/completions", json=body, timeout=60.0, headers={"Authorization": "Bearer " + KEY}) as resp: ctype = resp.headers.get("content-type", "") for line in resp.iter_lines(): if not line.startswith("data:"): continue payload = line[5:].strip() if payload == "[DONE]": saw_done = True break chunk = json.loads(payload) kinds.add(chunk.get("object")) ch = (chunk.get("choices") or [{}])[0] if ch.get("finish_reason"): finishes += 1 ok = (saw_done and kinds == {"chat.completion.chunk"} and finishes == 1 and "text/event-stream" in ctype) return ("IMPLEMENTED" if ok else "IGNORED", "done=" + str(saw_done) + " objects=" + str(kinds) + " finish_reasons=" + str(finishes)) def stop_sequences(): r = client.chat.completions.create( model=MODEL, max_tokens=48, stop=["5"], messages=[{"role": "user", "content": "Count from 1 to 10, digits separated by spaces."}]) text = r.choices[0].message.content or "" return ("IMPLEMENTED" if "5" not in text else "IGNORED", repr(text[:40])) def seed(): def once(): r = client.chat.completions.create( model=MODEL, max_tokens=64, temperature=1.0, seed=1234, messages=[{"role": "user", "content": "Invent a two-sentence story about a lighthouse."}]) return r.choices[0].message.content a, b = once(), once() return ("IMPLEMENTED" if a == b else "IGNORED", "identical" if a == b else "diverged") def multiple_completions(): r = client.chat.completions.create( model=MODEL, max_tokens=16, n=3, temperature=1.0, messages=[{"role": "user", "content": "Name a colour."}]) return ("IMPLEMENTED" if len(r.choices) == 3 else "IGNORED", "choices=" + str(len(r.choices))) def tools(): spec = [{"type": "function", "function": { "name": "get_weather", "description": "Get the weather for a city.", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}}] r = client.chat.completions.create( model=MODEL, max_tokens=64, tools=spec, tool_choice="required", messages=[{"role": "user", "content": "Weather in Oslo?"}]) calls = r.choices[0].message.tool_calls or [] if not calls: return ("IGNORED", "tool_choice=required produced no call") json.loads(calls[0].function.arguments) return ("IMPLEMENTED", calls[0].function.name) def error_shape(): try: client.chat.completions.create( model=MODEL, max_tokens=-5, messages=[{"role": "user", "content": "hi"}]) except APIStatusError as e: b = e.response.json() if e.response is not None else {} err = b.get("error", {}) if isinstance(b, dict) else {} shaped = "message" in err and "type" in err return ("IMPLEMENTED" if shaped else "IGNORED", json.dumps(b)[:90]) return ("IGNORED", "invalid max_tokens was accepted") for name, fn in [("envelope", envelope), ("usage", usage), ("streaming", streaming), ("stop", stop_sequences), ("seed", seed), ("n", multiple_completions), ("tools", tools), ("error_shape", error_shape)]: probe(name, fn) width = max(len(n) for n, _, _ in results) for name, state, detail in results: print(name.ljust(width), state.ljust(12), detail) - Run it against the endpoint under test. The model name has to be one that endpoint actually serves.
PROBE_API_KEY=sk-... python probe.py https://api.example.com/v1 my-model
- Run it against a reference implementation you trust as well, using a model you know supports everything. Probes that report
IGNOREDon both are probes with a bug in them, not findings. This calibration step is the one people skip and it is what stops the report being fiction.
Reading the report
Read REJECTED as good news: a server that errors on n: 3 has told you the truth, and your client can handle it. Read IGNORED as a finding that needs a decision — shim it, avoid the parameter, or accept the loss and record why.
Three probes need care in interpretation. The seed probe is one-sided: divergent outputs prove the seed is not honoured, identical outputs are only evidence, so make the generation long and repeat it before concluding. The stop probe can fail because the sequence spans a token boundary rather than because stop is unimplemented; try a second sequence before believing it. And the tools probe depends on the model as well as the server — a very small model may fail to produce a call even where the server supports it, which is why tool_choice: "required" is used rather than relying on the model to choose.
Keep the raw response for every probe. The report is a summary and the summary is the thing you will disagree with in three months.
Keeping it honest in CI
The report’s value is not the first run, it is the diff. These are capabilities of a deployment, and a serving-software upgrade changes them in both directions without any announcement you will see.
- Commit the report for each endpoint as a checked-in file, one line per probe, sorted.
- Run the probes on a schedule — nightly is enough — and fail the job on any diff against the committed file, in either direction. A capability appearing is as much a reason to look as one disappearing, because it usually means the deployment moved.
- Assert in your application code against the same file, so a request that uses a parameter recorded as
IGNOREDraises at the boundary rather than degrading quietly. - Re-run against a reference endpoint in the same job. If both change in the same way, your probe broke; if only one changed, the endpoint did.
The event this catches is the one that has no other detector: the endpoint that quietly stopped honouring a parameter it used to honour, which otherwise reaches you as a gradual quality complaint with no deploy of yours to correlate against. What that costs to diagnose without a probe report is the entire argument for keeping one.