OneLinersCommand workbench
Guides
AI/ML / Software Engineering / DevOps & CI/CD

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.

150 min14 stepsChanges system stateRevision 1
Save or explore
Save to collectionCreate a collection in the sidebar first.
0 of 14 steps completed
Goal

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.

Supported environments
  • Node.js 22.22.2
  • Ollama 0.32.0
  • LM Studio 0.4.1
  • vLLM 0.23.0
Prerequisites
  • 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.
Operating boundary

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

System
  • 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.
Observable outcome
  • 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.

ContractDefines mandatory request fields, limits, fixtures, schema, tool shape, and failure behavior independently of a vendor.
Release manifestBinds the evidence to exact engines, artifacts, tokenizers, templates, host, drivers, and startup assumptions.
Provider configurationsMap stable aliases to authenticated loopback OpenAI-compatible base URLs without storing credentials.
Compatibility harnessPerforms bounded JSON and SSE probes, validates shapes, and refuses redirects, oversize bodies, malformed arguments, and unknown structures.
Evidence reportStores pass or fail, duration, shape metadata, and content hashes while excluding raw prompts and completions.
Portable adapterForwards only allowlisted fields and exposes unsupported capabilities as typed failures.
  1. An operator pins engine, model artifact, tokenizer, template, runtime options, and network boundary.
  2. The harness loads a synthetic contract and one provider configuration at a time.
  3. Each endpoint response is bounded before parsing, then validated against an independent local assertion.
  4. The report records measured deployment facts and hashes, never universal provider claims.
  5. A reviewer classifies official documentation and measured evidence in separate matrix cells.
  6. 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}}secret

Throwaway bearer token supplied to the Ollama-compatible endpoint or its reviewed proxy.

Example: stored-in-secret-manager
{{lmStudioToken}}secret

Throwaway LM Studio API token when authentication is enabled.

Example: stored-in-secret-manager
{{vllmToken}}secret

Explicit vLLM API key configured at server startup.

Example: stored-in-secret-manager
{{selectedProvider}}

Provider identifier selected only after the evidence review.

Example: vllm

Security 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.
01

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.
Example output / evidence
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.

02

config

Create the dependency-free test workspace

caution

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.
Configuration
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"
  }
}
JSON
Example output / evidence
Created local-api-compat/config, src, test, reports and package.json; npm dependencies: 0.

Checkpoint: Workspace contains only expected files

find . -maxdepth 2 -type f -print

Continue 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.

03

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.
File config/release-manifest.json
Configuration
{
  "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."
  }
}
Example output / evidence
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.json

Continue whenNo output and exit status 1 after all digests are supplied.

Stop whenAny artifact, tokenizer, template, or engine version cannot be identified.

04

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.
File config/providers.json
Configuration
{
  "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."
    }
  ]
}
Example output / evidence
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.
05

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.
File config/contract.json
Configuration
{
  "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"]
  }
}
Example output / evidence
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.

06

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.
File src/compat.mjs
Configuration
import { 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
}));
Example output / evidence
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.mjs

Continue whenNo output and exit status 0.

Stop whenThe reviewed source differs from the guide or a requested change would execute returned tool calls.

07

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.
File src/portable-client.mjs
Configuration
export 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
    };
  }
}
Example output / evidence
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.mjs

Continue whenNo output; provider-specific decisions remain in reviewed configuration and capability data.

Stop whenPortability requires forwarding undocumented provider-native options.

08

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.
File test/compat.test.mjs
Configuration
import 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");
});
Example output / evidence
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.mjs

Continue when12 passing tests and no network activity.

Stop whenA malformed response is repaired, ignored, or classified as a pass.

09

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.
Example output / evidence
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 -ltnp

Continue 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.

10

command

Run all contract probes and preserve measured evidence

caution

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.
Command
Fill variables0/3 ready

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.json
Example output / evidence
ollama 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.
11

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.
File reports/decision-matrix.json
Configuration
{
  "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."
}
Example output / evidence
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.

12

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 output / evidence
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.

13

verification

Integrate through a canary and verify failure behavior

caution

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.
Command
Fill variables0/1 ready

Values stay on this page and are never sent or saved.

node src/portable-client.mjs --provider {{selectedProvider}} --fixture config/contract.json --canary
Example output / evidence
CANARY 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 --canary

Continue 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.

14

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.
Example output / evidence
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

Static and offline suite validationnpm run check && npm testBoth source files pass syntax checks and all negative fixtures pass without a network request.
Live compatibility reportnpm run compatA redacted report contains independent results for all configured providers and binds them to the release-manifest hash.
No placeholder provenancegrep -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.

Safe checks
  • 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.

Safe checks
  • 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.

Safe checks
  • 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.

Safe checks
  • 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.

Safe checks
  • 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.

Safe checks
  • 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.

Safe checks
  • 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.

Safe checks
  • 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.

Safe checks
  • 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.

Safe checks
  • 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.

Safe checks
  • 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.

Safe checks
  • 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.

After the procedure

Alternatives and next steps

Consider these alternatives

  • Use provider-native APIs when the application needs native lifecycle, model-management, or generation controls and portability is not a requirement.
  • Place a reviewed gateway in front of providers when central authentication, quotas, tracing, or response normalization is required; test the gateway plus backend as a new deployment.
  • Maintain separate adapters for materially different contracts instead of building a permissive universal parser that hides incompatibilities.
  • Use a hosted API for workloads whose reliability, compliance, or operational requirements cannot be met by the local inference environment.

Operate it safely

  • Run the separate reproducible benchmark guide with workload-specific prompts and controlled artifacts before making capacity or quality claims.
  • Add cancellation, embeddings, multimodal input, log probabilities, batching, and provider error envelopes only when an application requirement exists and a negative test can be written.
  • Publish the contract revision and deployment digest with the architecture decision, then schedule revalidation after material changes and every 90 days.
  • Review privacy, retention, authorization, model license, vulnerability response, backup, monitoring, and incident procedures before production traffic.

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.

  1. Disable the application canary or provider feature flag and restore the last known-good endpoint, model alias, and contract revision.
  2. Stop only the newly started loopback test instances after confirming no other process depends on them.
  3. Unset and revoke temporary tokens; remove local reports that contain unexpected raw content.
  4. Keep the failed manifest, structural report, and root-cause note, then open a new evidence revision rather than editing a published result.

Evidence

Sources and review

Verified 2026-07-29Review due 2026-10-27
Ollama OpenAI compatibilityofficialOllama structured outputsofficialLM Studio OpenAI compatibility APIofficialLM Studio tool use through OpenAI compatibilityofficialLM Studio structured outputofficialvLLM OpenAI-compatible serverofficialvLLM benchmark online servingofficialJSON Schema 2020-12official