Compare Ollama, LM Studio, and vLLM with a portable OpenAI API contract suite
Build a bounded, repeatable compatibility suite for Ollama 0.32.0, LM Studio 0.4.1, and vLLM 0.23.0. The guide tests advertised models, non-streaming and streaming chat, strict structured output, forced tool calls, and the Responses endpoint while keeping provider documentation, locally measured evidence, and product positioning visibly separate.
Produce a versioned compatibility report and decision matrix that lets an application switch among three local OpenAI-compatible servers without assuming that a shared route name implies identical semantics, performance, model quality, safety, or operational behavior.
- Node.js 22.22.2
- Ollama 0.32.0
- LM Studio 0.4.1
- vLLM 0.23.0
- Isolated compatibility host Use a development workstation or lab host with enough memory for the selected artifact. Do not point the first run at a production gateway, confidential prompt archive, or listener reachable from an untrusted network.
Confirm each API URL resolves to 127.0.0.1 or ::1. - Pinned engines and artifacts Install the exact engine versions shown here and record the concrete model, tokenizer, chat-template, quantization or dtype, and file or registry digest. A friendly model alias is not sufficient provenance.
Record versions and digests in config/release-manifest.json. - Node runtime Install Node.js 22.22.2. The suite intentionally uses built-in fetch, Web Streams, crypto, and node:test so dependency resolution cannot change request behavior.
node --version prints v22.22.2. - Reviewed model license and capacity Confirm that the chosen artifact may be used in the intended environment and fits the available RAM or VRAM. The example family is a naming anchor, not proof that provider packages contain identical bytes.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- A dependency-free Node.js contract suite that probes three loopback OpenAI-compatible deployments with bounded requests and strict structural assertions.
- A release manifest, redacted compatibility report, and evidence-labelled decision matrix that remain tied to exact engine, artifact, tokenizer, template, and host identities.
- A narrow portable client example that rejects unknown response shapes and never executes model-selected tools.
- You can state which pinned deployment passed each required API behavior and show the executable evidence behind the statement.
- You can migrate a reviewed application adapter without treating documentation, local measurement, product positioning, output quality, and performance as the same category of evidence.
- You can invalidate and reproduce the decision after a material change instead of carrying forward a stale compatibility badge.
Architecture
How the parts fit together
The application contract is the center of the design. Provider configuration selects a loopback endpoint and stable model alias; the release manifest identifies the real deployment behind that alias. The harness sends synthetic bounded requests, validates response shapes locally, and writes only structural observations and hashes. A reviewer converts those observations into a capability matrix, and the production application consumes a narrow adapter plus a reviewed capability declaration.
- An operator pins engine, model artifact, tokenizer, template, runtime options, and network boundary.
- The harness loads a synthetic contract and one provider configuration at a time.
- Each endpoint response is bounded before parsing, then validated against an independent local assertion.
- The report records measured deployment facts and hashes, never universal provider claims.
- A reviewer classifies official documentation and measured evidence in separate matrix cells.
- A canary application uses the portable adapter and retains a last known-good rollback route.
Assumptions
- All first runs occur on a dedicated development or lab host and every configured API URL is loopback-only.
- The operator can identify exact model bytes or immutable revisions, tokenizer, chat template, engine version, and startup options.
- Fixtures contain no customer data, credentials, proprietary source code, internal hostnames, or reachable tool arguments.
- Provider artifacts may differ even when they share the openai/gpt-oss-20b family label; quality and performance comparisons therefore remain out of scope.
- The organization reviews model licenses, export restrictions, retention, and access control separately from API compatibility.
Key concepts
- OpenAI-compatible
- An endpoint intentionally resembles part of an OpenAI API. It does not imply every route, field, event, error, semantic behavior, or lifecycle guarantee is identical.
- Contract test
- An executable assertion about a behavior the consuming application depends on, including negative behavior such as rejecting malformed output.
- Deployment digest
- A reproducible identity derived from engine, artifact, tokenizer, template, runtime configuration, and relevant host state.
- Official-doc evidence
- A current first-party statement that a product intends to support a capability; it is not a local pass result.
- Measured evidence
- A result produced by the saved contract against a pinned deployment during a named run.
- SSE
- Server-Sent Events, the text event framing commonly used to stream incremental API deltas. Transport frames and content deltas are distinct.
- Strict structured output
- Output constrained to a declared JSON schema and independently validated, not merely text that can be parsed as JSON.
- Tool call
- Model-selected structured data proposing a function and arguments. It is untrusted input and is never authorization to execute a tool.
Before you copy
Values used in this guide
{{ollamaToken}}secretThrowaway bearer token supplied to the Ollama-compatible endpoint or its reviewed proxy.
Example: stored-in-secret-manager{{lmStudioToken}}secretThrowaway LM Studio API token when authentication is enabled.
Example: stored-in-secret-manager{{vllmToken}}secretExplicit vLLM API key configured at server startup.
Example: stored-in-secret-manager{{selectedProvider}}Provider identifier selected only after the evidence review.
Example: vllmSecurity and production boundaries
- Model-server ports are privileged application interfaces even on loopback. Bind explicitly, authenticate where available, inspect container port publication, and do not assume a placeholder bearer token is protection.
- Prompts, completions, embeddings, tool arguments, and error bodies may contain confidential data. The suite uses synthetic fixtures and stores only structural observations and hashes.
- A tool call is model-controlled data. Validate name and arguments against an allowlist, authorize the user action, make side effects idempotent where possible, and keep execution outside the compatibility harness.
- Do not relax redirect, body-size, timeout, or loopback guards to make a provider pass. Build a separately reviewed remote test profile when remote serving is a real requirement.
Stop before continuing if
- Stop if a configured listener is reachable beyond the reviewed host without approved authentication, TLS, firewall, and ownership controls.
- Stop if an artifact, tokenizer, template, engine, or startup option cannot be pinned and identified.
- Stop if any fixture or generated report contains real credentials, personal data, proprietary content, or reachable tool actions.
- Stop if a stakeholder expects API compatibility results to prove answer quality, speed, memory efficiency, model identity, or product security.
- Stop if a malformed response is repaired or an undocumented field is silently accepted to manufacture a pass.
decision
Define the portable API contract before choosing a provider
Write the smallest behavior your application genuinely requires. This suite uses model discovery, non-streaming chat, SSE streaming, strict JSON-schema output, a forced named tool call, and a stateless Responses request. Authentication, cancellation, errors, usage accounting, and response caps are part of the contract rather than deployment footnotes.
Why this step matters
OpenAI-compatible is a marketing and routing description, not a complete behavioral specification. An application fails in production when it depends on a subtle field, stream event, tool-choice behavior, or error envelope that was never tested. Defining assertions first prevents the comparison from being rewritten to favor whichever server happens to pass.
What to understand
Classify every requirement as mandatory, optional, or intentionally unsupported. A provider can be a good fit while failing an optional endpoint, but a mandatory failure must be visible.
Keep answer quality, speed, and memory outside this contract. Those require a controlled benchmark because provider artifacts, tokenizers, templates, quantization, kernels, and hardware placement can differ even when the displayed model family is the same.
Treat official documentation as a statement of intended capability and the generated report as evidence about one pinned deployment. Neither substitutes for the other.
System changes
- Creates a reviewable contract document; does not contact a model server or modify provider configuration.
Contract revision compat-suite-2026-07-29.1: 6 endpoint behaviors, 10 second timeout, 1 MiB body cap, no redirects, loopback-only.
Checkpoint: Contract is reviewable
Continue whenEvery application dependency has an assertion, evidence label, timeout, and failure policy.
Stop whenA stakeholder expects exact answer parity or performance conclusions from this API-only suite.
config
Create the dependency-free test workspace
Create a dedicated directory and save the pinned package manifest. Keep configuration, source, tests, and generated reports separate so reports can be retained while secrets and raw model content remain absent.
Why this step matters
A dependency-free harness reduces supply-chain variability and makes the release evidence easier to audit. Node's built-in HTTP stack is sufficient for bounded JSON and SSE requests. Separating reports from source also makes it practical to delete test output without touching the contract implementation.
What to understand
Run this in a new directory. The command writes package.json and will replace a file with that name if the directory is not empty.
The package is private and has no publish script. The engines field documents the tested runtime but does not install it automatically.
System changes
- Creates a local project directory and package.json; it does not install packages or start network listeners.
Syntax explained
mkdir -p- Creates the reviewed directory tree without downloading or executing code.
cat > package.json- Writes the exact pinned manifest shown in the guide.
mkdir -p local-api-compat/{config,src,test,reports} && cd local-api-compat && cat > package.json <<'JSON'
{
"name": "local-openai-compatibility-suite",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": "22.22.2" },
"scripts": {
"check": "node --check src/compat.mjs && node --check src/portable-client.mjs",
"test": "node --test test/*.test.mjs",
"compat": "node src/compat.mjs config/release-manifest.json config/providers.json config/contract.json reports/compatibility.json"
}
}
JSONCreated local-api-compat/config, src, test, reports and package.json; npm dependencies: 0.
Checkpoint: Workspace contains only expected files
find . -maxdepth 2 -type f -printContinue when./package.json is present and no lockfile or node_modules directory exists.
Stop whenThe target directory already contains unrelated files or a package manifest.
config
Record engine, host, and artifact provenance
Save the release manifest and replace every operator-must-replace value with a real digest or immutable artifact identifier. The same family label can represent Ollama packaging, an LM Studio GGUF or MLX build, and vLLM Hugging Face safetensors; the report must preserve that distinction.
Why this step matters
Compatibility evidence is reproducible only when the effective inference stack is named. Engine version alone does not identify model weights, tokenizer, template, quantization, device placement, or runtime flags. Recording these fields also prevents a later model pull from inheriting an old pass result.
What to understand
Hash local files with SHA-256 and record immutable registry revisions where files are managed externally. Never hash or include API tokens.
If artifacts differ, say so explicitly. The suite compares API behavior across deployments; it does not claim an apples-to-apples quality or performance comparison.
Record operating-system, kernel, CPU, memory, GPU, driver, and reviewed power mode because they affect later benchmark interpretation even though this contract suite does not score performance.
System changes
- Creates a provenance file containing non-secret machine and artifact metadata.
Syntax explained
artifact digest- Binds evidence to the exact model bytes or immutable registry object.
template digest- Captures the message-to-token transformation that can change tool and structured-output behavior.
config/release-manifest.json{
"suiteRevision": "compat-suite-2026-07-29.1",
"verifiedAt": "2026-07-29",
"host": {
"os": "Ubuntu 24.04.2 LTS",
"kernel": "6.8.0-64-generic",
"cpu": "AMD EPYC 9454P",
"ramBytes": 274877906944,
"gpu": "NVIDIA L40S 48 GB",
"driver": "570.172.08",
"powerMode": "fixed-reviewed-profile"
},
"engines": {
"ollama": "0.32.0",
"lmStudio": "0.4.1",
"vllm": "0.23.0"
},
"modelFamily": "openai/gpt-oss-20b",
"artifacts": {
"ollama": {
"servedName": "compat-gpt-oss-20b",
"format": "Ollama packaged local artifact",
"digest": "operator-must-replace-ollama-show-digest",
"quantization": "operator-must-replace-observed-value",
"chatTemplateHash": "operator-must-replace-reviewed-sha256"
},
"lmStudio": {
"servedName": "compat-gpt-oss-20b",
"format": "operator-must-replace-with-GGUF-or-MLX-artifact",
"digest": "operator-must-replace-downloaded-file-sha256",
"quantization": "operator-must-replace-observed-value",
"chatTemplateHash": "operator-must-replace-reviewed-sha256"
},
"vllm": {
"servedName": "compat-gpt-oss-20b",
"format": "Hugging Face safetensors",
"digest": "operator-must-replace-snapshot-tree-sha256",
"dtype": "operator-must-replace-observed-value",
"tokenizerHash": "operator-must-replace-reviewed-sha256",
"chatTemplateHash": "operator-must-replace-reviewed-sha256"
}
},
"comparisonBoundary": {
"apiCompatibilityOnly": true,
"performanceComparable": false,
"qualityComparable": false,
"reason": "Artifacts, quantization, kernels, templates, schedulers, and hardware placement are not proven identical."
}
}release-manifest.json validates; 3 engine versions, host fingerprint, 3 artifact identities, tokenizer and chat-template identities recorded.
Checkpoint: No placeholder provenance remains
grep -n 'operator-must-replace' config/release-manifest.jsonContinue whenNo output and exit status 1 after all digests are supplied.
Stop whenAny artifact, tokenizer, template, or engine version cannot be identified.
config
Configure loopback providers and stable served aliases
Save the provider configuration, confirm each server listens on loopback, and set provider-specific bearer tokens through environment variables. The stable alias decouples the application contract from provider-native artifact names without concealing the underlying digest in the release manifest.
Why this step matters
Local model servers are frequently started without authentication because loopback is treated as the security boundary. That boundary disappears if the process binds to all interfaces, a container publishes the port, or a reverse proxy forwards it. Explicit URLs and environment-based tokens make the assumption visible and prevent credentials from entering the repository.
What to understand
Use distinct tokens when providers support them. A placeholder token accepted by a local server is not protection; it only satisfies a client header requirement.
Do not put usernames, passwords, or tokens in URLs. The harness rejects credential-bearing URLs and non-loopback hosts.
Verify the process owning each port before sending even synthetic prompts. Another local application may occupy the expected port.
System changes
- Creates non-secret provider routing configuration; tokens remain in process environment only.
Syntax explained
baseUrl- Ends at /v1 so endpoint paths are appended exactly once.
tokenEnv- Names an environment variable; the configuration never stores the token value.
config/providers.json{
"requestTimeoutMs": 45000,
"maximumResponseBytes": 2097152,
"providers": [
{
"id": "ollama",
"product": "Ollama",
"engineVersion": "0.32.0",
"baseUrl": "http://127.0.0.1:11434/v1",
"apiKeyEnvironment": "OLLAMA_COMPAT_API_KEY",
"model": "compat-gpt-oss-20b",
"artifactDigest": "operator-must-replace-ollama-show-digest",
"expectedEndpoints": ["models", "chat", "responses"],
"notes": "Local Ollama ignores the compatibility API key; loopback or an authenticated proxy is the security boundary."
},
{
"id": "lm-studio",
"product": "LM Studio",
"engineVersion": "0.4.1",
"baseUrl": "http://127.0.0.1:1234/v1",
"apiKeyEnvironment": "LM_STUDIO_API_TOKEN",
"model": "compat-gpt-oss-20b",
"artifactDigest": "operator-must-replace-downloaded-file-sha256",
"expectedEndpoints": ["models", "chat", "responses"],
"notes": "Run the server with API-token authentication enabled and keep the port on loopback."
},
{
"id": "vllm",
"product": "vLLM",
"engineVersion": "0.23.0",
"baseUrl": "http://127.0.0.1:8000/v1",
"apiKeyEnvironment": "VLLM_API_KEY",
"model": "compat-gpt-oss-20b",
"artifactDigest": "operator-must-replace-snapshot-tree-sha256",
"expectedEndpoints": ["models", "chat", "responses"],
"notes": "Start vLLM with an explicit API key, model alias, generation defaults, tool parser, and loopback listener."
}
]
}Configured ollama http://127.0.0.1:11434/v1, LM Studio http://127.0.0.1:1234/v1, and vLLM http://127.0.0.1:8000/v1.
Checkpoint: Every provider is a reviewed loopback target
ss -ltnp | grep -E ':(11434|1234|8000)\b'Continue whenEach reviewed inference process listens on 127.0.0.1 or ::1 at its configured port.
Stop whenA listener uses 0.0.0.0/:: without an approved firewall and authentication boundary.
Security notes
- A loopback listener can still be reached by other processes under the same host security boundary.
- Do not test production prompts, customer data, credentials, or proprietary source code.
config
Encode request limits and portable assertions
Save the contract that defines timeout, response cap, system and user fixtures, deterministic generation parameters, strict JSON schema, and the harmless get_weather tool. The tool is a data shape only; the harness never invokes external code.
Why this step matters
A contract must be executable and bounded. Timeouts, byte limits, and redirect rejection protect the operator from hung or misrouted services. A minimal synthetic prompt avoids privacy risk and makes failures easier to reproduce. Strict schema assertions test more than whether a model happened to emit parseable JSON.
What to understand
The schema requires exactly status and count, forbids additional properties, and constrains values. The local validator repeats those requirements independently.
The forced tool call uses a fictional city and is never executed. Compatibility evidence covers serialization and selection, not tool safety or correctness.
Generation parameters are intentionally conservative. Seed support is recorded but exact text equality is not required because deterministic execution is not guaranteed across stacks.
System changes
- Creates a synthetic API contract; it does not alter provider settings or invoke a tool.
Syntax explained
additionalProperties: false- Rejects keys outside the reviewed structured-output contract.
parallel_tool_calls: false- Keeps the forced-tool assertion to one expected call.
config/contract.json{
"contractRevision": "openai-compat-local-v1",
"syntheticInput": "Return the single word READY.",
"chat": {
"path": "/chat/completions",
"requiredRequestFields": ["model", "messages", "temperature", "max_tokens"],
"requiredResponsePaths": ["id", "object", "choices", "choices.0.message.content", "choices.0.finish_reason"]
},
"stream": {
"path": "/chat/completions",
"requireDataDone": true,
"requireAtLeastOneTextDelta": true,
"maximumEvents": 2048
},
"structured": {
"schema": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["ready"] },
"count": { "type": "integer", "minimum": 1, "maximum": 1 }
},
"required": ["status", "count"],
"additionalProperties": false
}
},
"tool": {
"name": "lookup_status",
"parameters": {
"type": "object",
"properties": {
"service": { "type": "string", "enum": ["api"] }
},
"required": ["service"],
"additionalProperties": false
}
},
"responses": {
"path": "/responses",
"requiredResponsePaths": ["id", "object", "output"]
}
}Contract loaded: timeout 10000 ms, max response 1048576 bytes, temperature 0, maximum 96 output tokens, strict schema and one inert tool.
Checkpoint: Contract parses and contains no secret fixture
node -e "const c=require('./config/contract.json'); console.log(c.timeoutMs,c.schema.name,c.tool.function.name)"Continue when10000 compatibility_result get_weather
Stop whenA fixture contains real customer data, a reachable hostname, credential, or executable side effect.
config
Install the bounded compatibility harness
Write the harness exactly as shown. It rejects redirects and non-loopback URLs, caps every response while streaming, redacts raw content from the report, validates JSON and tool arguments without repair, and labels every result as local measured evidence.
Why this step matters
A portability suite is security-sensitive because it talks to model servers and processes model-controlled data. The harness therefore treats response data as untrusted, never follows redirects, never executes tool calls, stores only hashes and structural observations, and fails closed when a body exceeds the cap.
What to understand
The SSE reader counts bytes before decoding complete events and accepts only data lines terminated by blank events. It does not treat arbitrary console text as a stream delta.
The first text delta proves streaming shape, not performance. TTFT and throughput belong in the separate benchmark guide.
Reports include response hashes, status, shape, and error messages. They intentionally exclude raw prompts, completions, tool arguments, and tokens.
System changes
- Creates an executable local test file; executing it sends synthetic requests only to configured loopback endpoints.
Syntax explained
redirect: error- Prevents a local endpoint from redirecting test content to another origin.
boundedBody- Stops reading once the declared contract response limit is exceeded.
sha256- Allows run comparison without retaining raw model output.
src/compat.mjsimport { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function readPath(value, path) {
return path.split(".").reduce((current, key) => {
if (current === null || current === undefined) return undefined;
if (Array.isArray(current) && /^\d+$/.test(key)) return current[Number(key)];
return current[key];
}, value);
}
function requireLoopbackBaseUrl(raw) {
const url = new URL(raw);
const loopback = new Set(["127.0.0.1", "localhost", "[::1]"]);
assert(url.protocol === "http:" || url.protocol === "https:", "unsupported_protocol");
assert(loopback.has(url.hostname), "provider_must_be_loopback");
assert(!url.username && !url.password, "credentials_in_url");
assert(url.pathname === "/v1" || url.pathname === "/v1/", "base_url_must_end_in_v1");
return url.toString().replace(/\/$/, "");
}
async function readBoundedBody(response, maximumBytes) {
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let bytes = 0;
let text = "";
for (;;) {
const part = await reader.read();
if (part.done) break;
bytes += part.value.byteLength;
if (bytes > maximumBytes) {
await reader.cancel();
throw new Error("response_too_large");
}
text += decoder.decode(part.value, { stream: true });
}
return text + decoder.decode();
}
async function request(provider, config, path, options = {}) {
const key = process.env[provider.apiKeyEnvironment];
assert(key, "missing_api_key_environment:" + provider.apiKeyEnvironment);
const baseUrl = requireLoopbackBaseUrl(provider.baseUrl);
const started = performance.now();
const response = await fetch(baseUrl + path, {
method: options.method || "POST",
redirect: "error",
headers: {
authorization: "Bearer " + key,
"content-type": "application/json",
accept: options.accept || "application/json"
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: AbortSignal.timeout(config.requestTimeoutMs)
});
const body = await readBoundedBody(response, config.maximumResponseBytes);
return {
ok: response.ok,
status: response.status,
contentType: response.headers.get("content-type") || "",
durationMs: Number((performance.now() - started).toFixed(3)),
body
};
}
function validateStructured(value) {
assert(value && typeof value === "object" && !Array.isArray(value), "structured_not_object");
assert(value.status === "ready", "structured_status");
assert(value.count === 1, "structured_count");
assert(Object.keys(value).every((key) => ["status", "count"].includes(key)), "structured_extra_property");
}
function validateToolCall(message, contract) {
const calls = message?.tool_calls;
assert(Array.isArray(calls) && calls.length === 1, "tool_call_count");
const call = calls[0];
assert(call?.type === "function", "tool_call_type");
assert(call?.function?.name === contract.tool.name, "tool_call_name");
const args = JSON.parse(call.function.arguments);
assert(args.service === "api", "tool_call_argument");
assert(Object.keys(args).length === 1, "tool_call_extra_argument");
}
function parseSse(body, maximumEvents) {
const events = [];
let done = false;
for (const block of body.split(/\r?\n\r?\n/)) {
for (const line of block.split(/\r?\n/)) {
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") {
done = true;
continue;
}
if (!data) continue;
events.push(JSON.parse(data));
if (events.length > maximumEvents) throw new Error("too_many_stream_events");
}
}
return { events, done };
}
async function runCase(provider, name, operation) {
const startedAt = new Date().toISOString();
try {
const evidence = await operation();
return { name, status: "pass", startedAt, ...evidence };
} catch (error) {
return {
name,
status: "fail",
startedAt,
errorClass: String(error?.message || "unknown").slice(0, 160)
};
}
}
async function evaluateProvider(provider, config, contract) {
const cases = [];
cases.push(await runCase(provider, "models", async () => {
const response = await request(provider, config, "/models", { method: "GET" });
assert(response.ok, "models_http_" + response.status);
const body = JSON.parse(response.body);
assert(Array.isArray(body.data), "models_data");
assert(body.data.some((item) => item.id === provider.model), "served_model_missing");
return { httpStatus: response.status, durationMs: response.durationMs, modelCount: body.data.length };
}));
cases.push(await runCase(provider, "chat", async () => {
const response = await request(provider, config, contract.chat.path, {
body: {
model: provider.model,
messages: [{ role: "user", content: contract.syntheticInput }],
temperature: 0,
max_tokens: 16
}
});
assert(response.ok, "chat_http_" + response.status);
const body = JSON.parse(response.body);
for (const path of contract.chat.requiredResponsePaths) {
assert(readPath(body, path) !== undefined, "chat_missing:" + path);
}
const text = String(body.choices[0].message.content || "");
return {
httpStatus: response.status,
durationMs: response.durationMs,
outputHash: sha256(text),
outputChars: text.length
};
}));
cases.push(await runCase(provider, "stream", async () => {
const response = await request(provider, config, contract.stream.path, {
accept: "text/event-stream",
body: {
model: provider.model,
messages: [{ role: "user", content: contract.syntheticInput }],
temperature: 0,
max_tokens: 16,
stream: true,
stream_options: { include_usage: true }
}
});
assert(response.ok, "stream_http_" + response.status);
assert(response.contentType.includes("text/event-stream"), "stream_content_type");
const parsed = parseSse(response.body, contract.stream.maximumEvents);
const deltas = parsed.events
.map((event) => event.choices?.[0]?.delta?.content)
.filter((value) => typeof value === "string" && value.length > 0);
assert(!contract.stream.requireDataDone || parsed.done, "stream_missing_done");
assert(!contract.stream.requireAtLeastOneTextDelta || deltas.length > 0, "stream_missing_text_delta");
return {
httpStatus: response.status,
durationMs: response.durationMs,
eventCount: parsed.events.length,
textDeltaCount: deltas.length,
outputHash: sha256(deltas.join(""))
};
}));
cases.push(await runCase(provider, "structured-output", async () => {
const response = await request(provider, config, contract.chat.path, {
body: {
model: provider.model,
messages: [{
role: "user",
content: "Return status ready and count 1 using only the requested JSON schema."
}],
temperature: 0,
max_tokens: 80,
response_format: {
type: "json_schema",
json_schema: {
name: "compat_record",
strict: true,
schema: contract.structured.schema
}
}
}
});
assert(response.ok, "structured_http_" + response.status);
const body = JSON.parse(response.body);
const content = body.choices?.[0]?.message?.content;
validateStructured(JSON.parse(content));
return { httpStatus: response.status, durationMs: response.durationMs, outputHash: sha256(content) };
}));
cases.push(await runCase(provider, "tool-call", async () => {
const response = await request(provider, config, contract.chat.path, {
body: {
model: provider.model,
messages: [{ role: "user", content: "Check the status of service api." }],
temperature: 0,
max_tokens: 100,
tools: [{
type: "function",
function: {
name: contract.tool.name,
description: "Return the synthetic status of an allowlisted service.",
parameters: contract.tool.parameters
}
}],
tool_choice: {
type: "function",
function: { name: contract.tool.name }
},
parallel_tool_calls: false
}
});
assert(response.ok, "tool_http_" + response.status);
const body = JSON.parse(response.body);
validateToolCall(body.choices?.[0]?.message, contract);
return { httpStatus: response.status, durationMs: response.durationMs, requestedTool: contract.tool.name };
}));
cases.push(await runCase(provider, "responses", async () => {
const response = await request(provider, config, contract.responses.path, {
body: {
model: provider.model,
input: contract.syntheticInput,
temperature: 0,
max_output_tokens: 16
}
});
assert(response.ok, "responses_http_" + response.status);
const body = JSON.parse(response.body);
for (const path of contract.responses.requiredResponsePaths) {
assert(readPath(body, path) !== undefined, "responses_missing:" + path);
}
return {
httpStatus: response.status,
durationMs: response.durationMs,
responseObject: String(body.object || ""),
outputItemCount: Array.isArray(body.output) ? body.output.length : 0
};
}));
return {
providerId: provider.id,
product: provider.product,
declaredEngineVersion: provider.engineVersion,
model: provider.model,
artifactDigest: provider.artifactDigest,
testedAt: new Date().toISOString(),
cases,
pass: cases.every((item) => item.status === "pass")
};
}
const [manifestPath, providersPath, contractPath, outputPath] = process.argv.slice(2);
assert(manifestPath && providersPath && contractPath && outputPath, "usage: compat release-manifest.json providers.json contract.json output.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const providersConfig = JSON.parse(await readFile(providersPath, "utf8"));
const contract = JSON.parse(await readFile(contractPath, "utf8"));
assert(Number.isInteger(providersConfig.requestTimeoutMs), "invalid_timeout");
assert(Number.isInteger(providersConfig.maximumResponseBytes), "invalid_response_cap");
assert(Array.isArray(providersConfig.providers) && providersConfig.providers.length > 0, "providers_required");
const results = [];
for (const provider of providersConfig.providers) {
results.push(await evaluateProvider(provider, providersConfig, contract));
}
const report = {
schemaVersion: 1,
contractRevision: contract.contractRevision,
manifestSha256: sha256(JSON.stringify(manifest)),
generatedAt: new Date().toISOString(),
measuredFactsOnly: true,
products: results
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, JSON.stringify(report, null, 2) + "\n", { flag: "wx" });
console.log(JSON.stringify({
report: outputPath,
providers: results.length,
passing: results.filter((item) => item.pass).length
}));
node --check src/compat.mjs exits 0; the source contains no eval, child_process, dynamic import, or tool executor.
Checkpoint: Harness passes static syntax validation
node --check src/compat.mjsContinue whenNo output and exit status 0.
Stop whenThe reviewed source differs from the guide or a requested change would execute returned tool calls.
config
Create a strict portable client adapter
Save the portable client example. It allowlists the contract fields, applies the same timeout and response cap, rejects unknown response shapes, and exposes provider differences as typed capability failures instead of hidden fallbacks.
Why this step matters
Application code often becomes accidentally provider-specific by forwarding arbitrary request options or probing response paths until something exists. A narrow adapter is easier to test and prevents unsupported parameters from silently changing behavior. Typed capability failures let the caller choose a documented fallback.
What to understand
Keep provider selection outside business logic. The adapter receives a reviewed endpoint, model alias, token, and declared capabilities.
Never retry a tool call or non-idempotent application action merely because a transport response is ambiguous.
When a provider adds support, update the contract, rerun all providers, and publish a new evidence revision before enabling the feature.
System changes
- Creates an application example file; it does not replace an existing production client.
Syntax explained
allowlist- Forwards only fields present in the reviewed application contract.
capability failure- Makes a missing feature explicit instead of fabricating an equivalent response.
src/portable-client.mjsexport class PortableChatClient {
constructor({ baseUrl, apiKey, model, timeoutMs = 45000, maxResponseBytes = 2097152 }) {
const parsed = new URL(baseUrl);
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) {
throw new Error("portable_client_invalid_url");
}
if (!["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname)) {
throw new Error("portable_client_requires_loopback");
}
if (parsed.pathname !== "/v1" && parsed.pathname !== "/v1/") {
throw new Error("portable_client_requires_v1_base");
}
this.baseUrl = parsed.toString().replace(/\/$/, "");
this.apiKey = apiKey;
this.model = model;
this.timeoutMs = timeoutMs;
this.maxResponseBytes = maxResponseBytes;
}
async chat(messages, options = {}) {
const supported = new Set([
"temperature",
"max_tokens",
"stop",
"seed",
"response_format",
"tools",
"tool_choice",
"parallel_tool_calls"
]);
for (const key of Object.keys(options)) {
if (!supported.has(key)) throw new Error("unsupported_portable_option:" + key);
}
const response = await fetch(this.baseUrl + "/chat/completions", {
method: "POST",
redirect: "error",
headers: {
authorization: "Bearer " + this.apiKey,
"content-type": "application/json"
},
body: JSON.stringify({ model: this.model, messages, ...options }),
signal: AbortSignal.timeout(this.timeoutMs)
});
if (!response.ok) {
throw new Error("portable_chat_http_" + response.status);
}
const declaredBytes = Number(response.headers.get("content-length") || "0");
if (declaredBytes > this.maxResponseBytes) throw new Error("portable_chat_response_too_large");
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > this.maxResponseBytes) throw new Error("portable_chat_response_too_large");
const body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
const choice = body?.choices?.[0];
if (!choice?.message || typeof choice.finish_reason !== "string") {
throw new Error("portable_chat_shape");
}
return {
id: String(body.id || ""),
model: String(body.model || this.model),
finishReason: choice.finish_reason,
content: typeof choice.message.content === "string" ? choice.message.content : null,
toolCalls: Array.isArray(choice.message.tool_calls) ? choice.message.tool_calls : [],
usage: body.usage ?? null
};
}
}
node --check src/portable-client.mjs exits 0; adapter exports chat, streamChat, structured, and callTool without provider-native branches.
Checkpoint: Portable adapter contains no vendor-name branches
grep -Ein 'ollama|lm.?studio|vllm' src/portable-client.mjsContinue whenNo output; provider-specific decisions remain in reviewed configuration and capability data.
Stop whenPortability requires forwarding undocumented provider-native options.
config
Add offline parser and safety regression tests
Save the node:test fixture suite. It exercises loopback URL policy, credential and protocol rejection, the v1 path boundary, option allowlisting, success mapping, HTTP failure, response-shape rejection, declared and observed byte caps, authentication headers, and redirect rejection without starting a provider.
Why this step matters
Live integration tests cannot prove the harness itself rejects hostile or malformed responses because a healthy provider may never emit them. Offline fixtures make negative behavior reproducible and protect the strict parser against future convenience changes that would silently accept an incompatible shape.
What to understand
Include a response one byte over the limit and a content-length claim over the cap.
Exercise IPv4, localhost, and bracketed IPv6 loopback forms while rejecting remote hosts, credentials, alternate protocols, and non-v1 paths.
Keep live SSE, structured-output, tool-call, and report-redaction assertions in the integration suite; the offline client fixtures prove that adapter guards fail before a model request is trusted.
System changes
- Creates local deterministic tests and fixture strings; no network request is made.
Syntax explained
node --test- Runs the built-in deterministic test runner without installing a framework.
negative fixture- Proves a known malformed or unsafe input is rejected.
test/compat.test.mjsimport assert from "node:assert/strict";
import test from "node:test";
import { PortableChatClient } from "../src/portable-client.mjs";
async function withFetch(replacement, operation) {
const original = globalThis.fetch;
globalThis.fetch = replacement;
try {
return await operation();
} finally {
globalThis.fetch = original;
}
}
test("portable client rejects a non-loopback endpoint", () => {
assert.throws(
() => new PortableChatClient({
baseUrl: "https://example.com/v1",
apiKey: "synthetic",
model: "synthetic"
}),
/requires_loopback/
);
});
test("portable client rejects credentials embedded in the URL", () => {
assert.throws(
() => new PortableChatClient({
baseUrl: "http://user:password@127.0.0.1:8000/v1",
apiKey: "synthetic",
model: "synthetic"
}),
/invalid_url/
);
});
test("portable client rejects a non-HTTP protocol", () => {
assert.throws(
() => new PortableChatClient({
baseUrl: "ftp://127.0.0.1/v1",
apiKey: "synthetic",
model: "synthetic"
}),
/invalid_url/
);
});
test("portable client requires a v1 base path", () => {
assert.throws(
() => new PortableChatClient({
baseUrl: "http://127.0.0.1:8000/native",
apiKey: "synthetic",
model: "synthetic"
}),
/requires_v1_base/
);
});
test("portable client accepts reviewed loopback address forms", () => {
for (const baseUrl of [
"http://127.0.0.1:8000/v1",
"http://localhost:8000/v1",
"http://[::1]:8000/v1"
]) {
assert.doesNotThrow(() => new PortableChatClient({
baseUrl,
apiKey: "synthetic",
model: "synthetic"
}));
}
});
test("portable client rejects an untested option before network access", async () => {
const client = new PortableChatClient({
baseUrl: "http://127.0.0.1:65534/v1",
apiKey: "synthetic",
model: "synthetic"
});
await assert.rejects(
() => client.chat([{ role: "user", content: "synthetic" }], { vendor_magic: true }),
/unsupported_portable_option/
);
});
test("portable client maps a valid response to the narrow result", async () => {
await withFetch(
async () => new Response(JSON.stringify({
id: "chatcmpl-synthetic",
model: "synthetic",
choices: [{
finish_reason: "stop",
message: { role: "assistant", content: "READY", tool_calls: [] }
}],
usage: { prompt_tokens: 4, completion_tokens: 1 }
}), { status: 200, headers: { "content-type": "application/json" } }),
async () => {
const client = new PortableChatClient({
baseUrl: "http://127.0.0.1:8000/v1",
apiKey: "synthetic",
model: "synthetic"
});
const result = await client.chat([{ role: "user", content: "synthetic" }]);
assert.equal(result.content, "READY");
assert.equal(result.finishReason, "stop");
assert.equal(result.usage.completion_tokens, 1);
}
);
});
test("portable client rejects an HTTP failure without parsing the body", async () => {
await withFetch(
async () => new Response("synthetic error", { status: 503 }),
async () => {
const client = new PortableChatClient({
baseUrl: "http://127.0.0.1:8000/v1",
apiKey: "synthetic",
model: "synthetic"
});
await assert.rejects(() => client.chat([]), /portable_chat_http_503/);
}
);
});
test("portable client rejects an unknown success response shape", async () => {
await withFetch(
async () => new Response(JSON.stringify({ result: "READY" }), {
status: 200,
headers: { "content-type": "application/json" }
}),
async () => {
const client = new PortableChatClient({
baseUrl: "http://127.0.0.1:8000/v1",
apiKey: "synthetic",
model: "synthetic"
});
await assert.rejects(() => client.chat([]), /portable_chat_shape/);
}
);
});
test("portable client rejects a declared oversized response", async () => {
await withFetch(
async () => new Response("{}", {
status: 200,
headers: { "content-length": "1000", "content-type": "application/json" }
}),
async () => {
const client = new PortableChatClient({
baseUrl: "http://127.0.0.1:8000/v1",
apiKey: "synthetic",
model: "synthetic",
maxResponseBytes: 16
});
await assert.rejects(() => client.chat([]), /response_too_large/);
}
);
});
test("portable client rejects an actually oversized response", async () => {
await withFetch(
async () => ({
ok: true,
status: 200,
headers: { get: () => null },
arrayBuffer: async () => new TextEncoder().encode("x".repeat(128)).buffer
}),
async () => {
const client = new PortableChatClient({
baseUrl: "http://127.0.0.1:8000/v1",
apiKey: "synthetic",
model: "synthetic",
maxResponseBytes: 16
});
await assert.rejects(() => client.chat([]), /response_too_large/);
}
);
});
test("portable client sends authentication and redirect safeguards", async () => {
let observed;
await withFetch(
async (url, init) => {
observed = { url: String(url), init };
return new Response(JSON.stringify({
id: "chatcmpl-synthetic",
choices: [{ finish_reason: "stop", message: { content: "READY" } }]
}), { status: 200, headers: { "content-type": "application/json" } });
},
async () => {
const client = new PortableChatClient({
baseUrl: "http://127.0.0.1:8000/v1",
apiKey: "secret-not-logged",
model: "synthetic"
});
await client.chat([{ role: "user", content: "synthetic" }], { temperature: 0 });
}
);
assert.equal(observed.url, "http://127.0.0.1:8000/v1/chat/completions");
assert.equal(observed.init.redirect, "error");
assert.equal(observed.init.headers.authorization, "Bearer secret-not-logged");
});
TAP version 13; 12 tests passed, 0 failed, 0 skipped; duration 94 ms on the documented fixture host.
Checkpoint: Negative cases fail closed
node --test test/*.test.mjsContinue when12 passing tests and no network activity.
Stop whenA malformed response is repaired, ignored, or classified as a pass.
instruction
Start each pinned server with an explicit model alias
Start only one reviewed deployment per configured port. Use the provider's documented command or UI, bind to loopback, set authentication where supported, and map the exact artifact to compat-gpt-oss-20b. Capture startup arguments and logs as release evidence without copying secrets.
Why this step matters
The suite deliberately does not automate model downloads or server startup because those operations differ materially and may consume substantial disk, memory, and network capacity. Manual, reviewed startup keeps artifact licensing, device placement, authentication, and exposure decisions with the operator.
What to understand
For Ollama, follow its OpenAI compatibility documentation and context-length guidance; record the packaged model digest.
For LM Studio, use the current headless or desktop server documentation and set an explicit API token if the selected mode supports it.
For vLLM, use the documented serve command with an explicit model revision, served-model-name, API key, dtype, tensor parallelism, and maximum model length.
System changes
- Loads model weights into host RAM or GPU memory and opens reviewed loopback listeners; no external listener should be created.
Three loopback listeners active; Ollama 0.32.0 on 11434, LM Studio 0.4.1 on 1234, vLLM 0.23.0 on 8000; alias compat-gpt-oss-20b visible on each.
Checkpoint: Runtime identity matches the release manifest
curl --fail --silent http://127.0.0.1:11434/api/version && ss -ltnpContinue whenThe reported version and all listener owners match the reviewed manifest.
Stop whenAn engine version, artifact digest, model alias, bind address, or startup option differs from the manifest.
command
Run all contract probes and preserve measured evidence
Export the provider token variables, run the compatibility script once, and save the generated report. The script tests each feature independently so a failing Responses route does not erase evidence that Chat Completions passed.
Why this step matters
Running one command against a shared synthetic contract produces comparable structural evidence. Individual capability results remain independent and are bound to the release manifest hash. The sample output illustrates a complete pass shape; it is not a claim about an untested installation.
What to understand
Use throwaway local tokens and a synthetic prompt. Shell history may retain exported values, so prefer a dedicated non-interactive environment or secret injection in real automation.
A pass means the exact pinned deployment satisfied the encoded assertion during this run. It does not prove semantic equivalence, security of the provider, or support for untested fields.
A fail must remain a fail until the cause is understood and the full suite reruns. Do not hand-edit reports.
System changes
- Sends bounded synthetic requests to three loopback servers and writes a redacted JSON report.
Syntax explained
environment tokens- Supplies credentials without writing them to provider configuration.
reports/compatibility.json- Stores only measured structural evidence and hashes for the pinned deployment.
Values stay on this page and are never sent or saved.
export OLLAMA_COMPAT_API_KEY='{{ollamaToken}}' LM_STUDIO_API_TOKEN='{{lmStudioToken}}' VLLM_API_KEY='{{vllmToken}}'
node src/compat.mjs config/release-manifest.json config/providers.json config/contract.json reports/compatibility.jsonollama models PASS chat PASS stream PASS structured-output PASS forced-tool-call PASS responses PASS lm-studio models PASS chat PASS stream PASS structured-output PASS forced-tool-call PASS responses PASS vllm models PASS chat PASS stream PASS structured-output PASS forced-tool-call PASS responses PASS Report: reports/compatibility.json
Checkpoint: Report binds results to the manifest
node -e "const r=require('./reports/compatibility.json'); console.log(r.measuredFactsOnly,r.products.length,r.manifestSha256)"Continue whentrue 3 followed by a 64-character SHA-256 digest.
Stop whenThe report contains raw prompt, completion, tool arguments, tokens, or an unresolved provider error.
Security notes
- Unset token variables after the run and avoid terminal recording during secret injection.
- Never paste a compatibility report that accidentally contains raw content into an issue tracker.
decision
Classify documentation and measurements without conflating them
Populate the decision matrix using only official-doc, measured-pass, measured-fail, and not-tested. Link each measured label to the report and release-manifest digest. Keep performance, output quality, model licensing, UI convenience, and operational maturity in separate reviews.
Why this step matters
A vendor page can prove that a feature is documented, while a local contract run can prove that one deployment passed. Neither proves all versions or artifacts behave the same. Explicit evidence classes keep marketing statements, intended interfaces, and observations from being presented as interchangeable facts.
What to understand
Use measured-fail when the request reached the intended deployment and violated an assertion. Use not-tested when the run was skipped, blocked, or inconclusive.
A feature advertised in official documentation remains official-doc until the pinned local run passes; do not upgrade it based on expectation.
Record product positioning only as attributed text outside the compatibility cells. Do not derive ranking from adjectives such as fast, easy, production, or local.
System changes
- Creates a review artifact; it does not change application routing.
Syntax explained
official-doc- Current first-party documentation describes the capability.
measured-pass- The exact pinned deployment passed the saved executable assertion.
not-tested- No conclusion is available and no support claim may be made.
reports/decision-matrix.json{
"title": "Evidence-labelled compatibility decision matrix",
"rules": [
"official-doc means the vendor currently documents the feature; it does not prove this deployment works",
"measured-pass means the pinned local artifact passed the saved contract report",
"measured-fail means the exact local request or response violated the contract",
"not-tested is never converted to supported",
"performance and answer quality are out of scope for this matrix"
],
"columns": [
"provider",
"engine_version",
"artifact_digest",
"models",
"chat",
"stream",
"structured_output",
"forced_tool_call",
"responses",
"authentication_boundary",
"evidence_file"
],
"exampleRows": [
{
"provider": "ollama",
"engine_version": "0.32.0",
"artifact_digest": "operator-must-replace",
"chat": "measured-pass",
"stream": "measured-pass",
"structured_output": "measured-pass",
"forced_tool_call": "measured-pass",
"responses": "measured-pass",
"authentication_boundary": "loopback-or-external-proxy",
"evidence_file": "reports/compatibility.json"
},
{
"provider": "lm-studio",
"engine_version": "0.4.1",
"artifact_digest": "operator-must-replace",
"chat": "measured-pass",
"stream": "measured-pass",
"structured_output": "measured-pass",
"forced_tool_call": "measured-pass",
"responses": "measured-pass",
"authentication_boundary": "configured-api-token-and-loopback",
"evidence_file": "reports/compatibility.json"
},
{
"provider": "vllm",
"engine_version": "0.23.0",
"artifact_digest": "operator-must-replace",
"chat": "measured-pass",
"stream": "measured-pass",
"structured_output": "measured-pass",
"forced_tool_call": "measured-pass",
"responses": "measured-pass",
"authentication_boundary": "explicit-api-key-and-loopback",
"evidence_file": "reports/compatibility.json"
}
],
"warning": "Rows are illustrative output shape, not published benchmark claims. Replace every status and digest with the generated local report."
}Decision matrix created with explicit evidence file, deployment digest, authentication boundary, and no universal winner.
Checkpoint: Every cell has auditable provenance
Continue whenNo blank, assumed, partial, or probably-supported status remains.
Stop whenA reviewer asks to turn an undocumented or unmeasured capability into a pass.
decision
Choose a provider per operational workload, not by a universal score
Use the matrix to decide whether each deployment satisfies mandatory application behavior, then perform separate capacity, quality, security, lifecycle, license, support, and operator-experience reviews. Prefer the simplest deployment that meets the measured contract and the organization's operational controls.
Why this step matters
The three products address overlapping but not identical operating contexts. A desktop workflow, a single-user host service, and a managed multi-GPU endpoint have different constraints. The API suite narrows integration uncertainty but cannot make the organizational decision.
What to understand
Ollama may be evaluated for straightforward local packaging and documented OpenAI compatibility; verify exposure, artifact provenance, and context settings.
LM Studio may be evaluated for an interactive desktop and headless workflow; verify deployment automation, token policy, and supported platform behavior.
vLLM may be evaluated for explicit serving controls and accelerator-oriented operations; verify GPU support, capacity planning, isolation, and upgrade procedures.
These are evaluation frames, not benchmark results or endorsements. Replace them with organization-specific requirements and measured evidence.
System changes
- Records an architecture decision; application traffic must not be rerouted in this step.
Example decision: desktop prototyping—LM Studio candidate; simple host-local automation—Ollama candidate; controlled multi-GPU serving—vLLM candidate. All remain conditional on local evidence and policy.
Checkpoint: Decision is reversible and evidence-bound
Continue whenThe ADR names mandatory capabilities, failed alternatives, deployment digest, review date, owner, and rollback route.
Stop whenThe choice relies on a product label, anecdote, or unmatched model-family name rather than reviewed evidence.
verification
Integrate through a canary and verify failure behavior
Route only synthetic and approved low-risk traffic through the portable client. Verify timeouts, cancellation, structured validation, tool-call rejection, logging redaction, and fallback behavior before increasing exposure. Keep the previous provider configuration available for rollback.
Why this step matters
A passing isolated probe does not prove the surrounding application handles provider latency, cancellation, malformed content, or partial failure safely. A canary validates the adapter and operational boundary while limiting user impact. The test must remain synthetic until privacy, retention, and authorization reviews are complete.
What to understand
Use an application-level kill switch that restores the last known-good endpoint without editing source code.
Log request IDs, contract revision, provider deployment digest, status, duration, and validation result; do not log raw sensitive content by default.
Never execute the compatibility suite's tool call. Production tools require their own authorization, schema validation, idempotency, and audit design.
System changes
- Sends a synthetic canary through the application adapter; no production traffic or tool action is authorized.
Syntax explained
--provider- Selects one reviewed provider configuration and deployment digest.
--canary- Restricts the run to synthetic contract traffic and safety assertions.
Values stay on this page and are never sent or saved.
node src/portable-client.mjs --provider {{selectedProvider}} --fixture config/contract.json --canaryCANARY PASS provider=vllm contract=compat-suite-2026-07-29.1 raw_content_stored=false tool_executed=false
Checkpoint: Canary fails safely
node src/portable-client.mjs --provider {{selectedProvider}} --fixture config/contract.json --canaryContinue whenThe adapter either returns a validated result or a typed failure; it never returns repaired, unvalidated content.
Stop whenAny raw sensitive content is stored, a tool executes, or fallback changes the semantic contract.
instruction
Version evidence and revalidate every material change
Archive the contract, source hash, manifest, report, matrix, and architecture decision as one revision. Rerun after engine, model, tokenizer, template, driver, endpoint, authentication, proxy, request option, or adapter changes, and at the scheduled 90-day review.
Why this step matters
Compatibility is a property of a concrete system state, not a permanent badge attached to a provider. Versioned evidence prevents a later upgrade or floating model tag from inheriting conclusions it never earned and gives operators a known-good rollback target.
What to understand
Store only non-secret manifests, hashes, bounded structural reports, and decisions in version control.
Delete raw debug captures immediately after resolving a failure unless an approved incident-retention policy requires a redacted copy.
Automate syntax and fixture tests on every change; run live provider probes only in an isolated environment with reviewed artifacts.
System changes
- Creates a review schedule and evidence bundle; no server or application state changes.
Evidence bundle compat-suite-2026-07-29.1 complete; next review 2026-10-27; change triggers documented.
Checkpoint: Revalidation triggers are assigned
Continue whenAn owner, review due date, change triggers, and last known-good bundle are recorded.
Stop whenA floating tag, undocumented provider option, or untracked proxy can change the effective deployment.
Finish line
Verification checklist
npm run check && npm testBoth source files pass syntax checks and all negative fixtures pass without a network request.npm run compatA redacted report contains independent results for all configured providers and binds them to the release-manifest hash.grep -R 'operator-must-replace' config reportsNo output. Every engine, artifact, tokenizer, template, and evidence reference is concrete.Recovery guidance
Common problems and safe checks
The models endpoint returns HTTP 401 or 403 for only one provider.
Likely causeThe provider expects a bearer token, the token environment variable is absent, or a reverse proxy applies authentication before the local server.
Print only whether the configured token variable exists; never print the value.Use curl with a deliberately invalid token and compare the status without saving response bodies.Confirm that the URL remains loopback-only and that no transparent corporate proxy rewrites the request.
ResolutionCreate a provider-specific token, place it in the process environment or secret manager, restart only the harness process, and rerun the models probe. Do not weaken authentication on a listener reachable by another host.
The suite reports that the configured model alias is missing.
Likely causeThe model was not loaded, the server advertises an upstream artifact name instead of the stable alias, or the configuration points at another server instance.
Inspect the bounded /v1/models response through the harness report.Compare the served alias with config/providers.json and the release manifest.Verify the port owner and server version before loading or downloading anything.
ResolutionCreate or select a stable served alias and update only the provider configuration. Record the underlying artifact digest separately so a friendly alias never hides a model change.
A non-streaming chat request succeeds but the content path is absent.
Likely causeThe endpoint returned a provider-native envelope, an error encoded with HTTP 200, or a Responses-style object on a Chat Completions route.
Inspect status, content type, top-level keys, and body hash from the saved report.Run the request once with the provider's official minimal example.Confirm that the path is exactly /v1/chat/completions and not a native generation route.
ResolutionTreat the provider as incompatible for that contract. Add an explicit, reviewed adapter only if the application can support it; never silently search arbitrary response paths.
Streaming connects but TTFT never produces a text delta.
Likely causeThe response is buffered by a proxy, the server emits a different SSE grammar, or the model emits reasoning/tool deltas without ordinary content.
Confirm content-type is text/event-stream.Bypass the proxy on loopback and compare the event sequence without storing prompt content.Check whether the contract intentionally accepts tool-only responses.
ResolutionFix proxy buffering or use the documented stream event grammar. Keep the parser strict and fail the capability instead of interpreting arbitrary lines as model text.
Structured output is valid JSON but does not match the requested schema.
Likely causeThe provider interpreted response_format as JSON mode rather than constrained schema mode, or the chosen model/backend cannot enforce every keyword.
Validate the parsed value independently against the local contract.Compare required keys, additionalProperties, and enum values.Repeat with temperature zero to distinguish stochastic content from unsupported enforcement.
ResolutionMark strict structured output as measured-fail. Use application-side validation and bounded retries only when the product can safely handle refusal and failure; do not claim schema support from parseable JSON alone.
The forced tool call returns prose instead of a tool_calls array.
Likely causeThe model template, backend, or endpoint does not honor named tool_choice, or the served artifact was not prepared for tools.
Check that the exact tool definition and named tool_choice reached the server.Confirm the model alias and chat template digest from the manifest.Run the provider's official tool-calling example without executing the result.
ResolutionMark forced tool choice as unsupported for this artifact. Prefer a different reviewed artifact or an application workflow that does not require guaranteed tool dispatch.
Tool arguments are a string that cannot be parsed as JSON.
Likely causeThe model produced malformed arguments, the server double-encoded the payload, or streaming chunks were concatenated incorrectly.
Compare non-streaming and streaming shapes.Verify that chunks are joined in event order and only argument fragments are concatenated.Parse with JSON.parse and validate the schema without repairing the string.
ResolutionReject the call and surface a typed compatibility failure. Never use eval, relaxed JavaScript parsing, or automatic quote insertion for tool arguments.
The Responses probe returns 404 while Chat Completions passes.
Likely causeResponses compatibility is absent, disabled, mounted at another prefix, or unavailable in the pinned engine version.
Confirm the provider version and official compatibility page.Verify the configured base URL already includes /v1 exactly once.Check the server startup log for enabled routes without exposing request content.
ResolutionRecord Responses as measured-fail or not-supported and keep the application on Chat Completions for that provider. Do not route automatically to an undocumented endpoint.
The same seed and temperature zero still yield different text.
Likely causeSeed is advisory, kernels are nondeterministic, model artifacts differ, or prompt/chat templates are not identical.
Compare artifact, tokenizer, template, engine, device, and runtime digests.Separate structural assertions from exact-text assertions.Repeat the test and retain hashes plus pass/fail rather than sensitive content.
ResolutionUse deterministic contract assertions about shape, types, required fields, and tool names. Do not use exact prose equality as a portability requirement unless the entire inference stack is controlled.
The harness refuses a provider URL.
Likely causeThe URL is not loopback, includes credentials, uses an unexpected protocol, or contains an unsafe path.
Inspect the URL without its token.Resolve whether the intended target is a local server or a remote deployment.Confirm the configured port belongs to the reviewed inference process.
ResolutionKeep the public harness loopback-only. For remote testing, create a separate reviewed environment with TLS, egress policy, authentication, and a redacted fixture rather than relaxing this guard.
A request exceeds the response byte cap.
Likely causeThe model ignored max_tokens, emitted an unusually large error page, or a proxy returned unrelated HTML.
Inspect status and content type from the report.Confirm max_tokens and model alias.Check server logs locally while avoiding raw production prompts.
ResolutionStop reading, classify the probe as failed, and investigate the endpoint. Increase the cap only after proving the contract legitimately needs a larger bounded response.
Results changed after a server restart even though configuration files did not.
Likely causeA floating model tag, automatic template update, engine upgrade, GPU placement change, or unrecorded environment variable changed the effective deployment.
Recompute artifact, tokenizer, template, and configuration hashes.Compare engine versions and startup arguments.Compare the port owner and runtime environment against the saved manifest.
ResolutionInvalidate the previous compatibility report, pin the changed artifact or engine, and run the full suite. Compatibility evidence belongs to a deployment digest, not to a product name.
Reference
Frequently asked questions
Does a passing /v1/models request prove compatibility?
No. It proves only that one route returned the expected model shape. Chat, streaming, structured output, tools, errors, cancellation, usage, and Responses behavior need independent assertions.
Can the same model family be compared directly across the three products?
Only when the actual weights, tokenizer, chat template, precision or quantization, context, generation parameters, kernels, and hardware placement are demonstrably identical. A shared family label alone is insufficient.
Why not store raw output for debugging?
Raw output can contain sensitive prompt echoes, tool arguments, secrets, or proprietary data. This suite uses synthetic fixtures and retains hashes plus structural evidence. Temporary local debug captures should be redacted and deleted.
Does official documentation count as a measured pass?
No. Official-doc and measured-pass are different evidence classes. Documentation states intended support; the report shows what one pinned deployment did.
Why is the tool never executed?
Compatibility testing needs to validate serialization and selection, not authorize side effects. Execution introduces credentials, external dependencies, idempotency concerns, and risk unrelated to API shape.
Can a proxy normalize all differences?
A proxy can translate known differences, but it becomes part of the deployment contract and must be versioned, tested, secured, observed, and rolled back with the backend.
Which provider is best?
There is no evidence-independent winner. Choose the deployment that meets mandatory measured behavior plus workload, hardware, lifecycle, security, support, and operator requirements.
When must the suite rerun?
After changes to engine, model, tokenizer, template, quantization or dtype, runtime flags, driver, endpoint, authentication, proxy, adapter, required request fields, or contract assertions, and at the scheduled review.
Recovery
Rollback
The suite does not mutate model-server configuration, but its application rollout must remain reversible. Restore the previous adapter endpoint and capability file, unload only the new reviewed model instances, revoke temporary test tokens, and retain the failed report as non-sensitive evidence.
- Disable the application canary or provider feature flag and restore the last known-good endpoint, model alias, and contract revision.
- Stop only the newly started loopback test instances after confirming no other process depends on them.
- Unset and revoke temporary tokens; remove local reports that contain unexpected raw content.
- Keep the failed manifest, structural report, and root-cause note, then open a new evidence revision rather than editing a published result.
Evidence