AI DevelopmentOpen Standards

Open Responses: One Agent Client for OpenAI, OpenRouter, Ollama and vLLM

October 28, 2026
14 min read
One fiber-optic cable fanning out into five glowing colored strands on a dark background
Share:

On 22 October 2026, at AGNTCon + MCPCon North America in San Jose, the Linux Foundation’s Agentic AI Foundation (AAIF) took in four projects: Agent Skills from Anthropic, Open Responses from OpenAI, OpenEnv from Hugging Face and OpenSandbox from Alibaba. Of the four, Open Responses is the one that changes the client code you write today. It’s an open spec, derived from OpenAI’s Responses API, for calling any model provider with the same items, streaming events and tool calls.

I cloned the spec repo (commit 8907bc3) and read the 2026-04-24 release, the OpenAPI file and the compliance runner. On 28 Oct 2026 I checked every provider’s docs, read Ollama’s and vLLM’s source where the docs were silent, and checked SDK signatures in openai 3.40.0 (PyPI) and 7.45.1 (npm). I didn’t call any API or run the suite: there’s no local model server on this machine.

1. The Problem: Every Provider Speaks a Slightly Different Chat API

Chat Completions became a de facto standard because everyone cloned it, and every clone drifts at the edges: how tool calls are shaped, where reasoning goes, which chunks a stream emits, what an error looks like. Anthropic still defines its own Messages API and Google its Interactions API, as InfoQ’s AGNTCon report points out. Your agent loop ends up encoding one provider’s assumptions, and switching means rewriting it.

Open Responses works at the wire level. It standardizes items, semantic streaming events and the tool-call loop, and it lets vendors add features under a prefixed namespace without breaking the core. If a server implements it, you swap providers by changing a base URL, a key and a model ID. The rest of this post checks how true that is today.

🏛️

AAIF projects

10

Up from 3 at the December 2025 launch; 4 added on 22 Oct 2026

📜

Latest spec

2026-04-24

Second dated release after 2026-01-15. Dated releases are immutable

⚖️

Licenses

Apache-2.0 + CC BY 4.0

Code and docs, per the charter adopted on 21 Oct 2026

⭐

GitHub

~1.2K stars

openresponses/openresponses on 28 Oct 2026. 9 maintainers from 7 companies

The honest caveat

It’s an open standard built on one vendor’s API. Simon Willison called it the standardization effort he most wanted, while noting he’d have preferred Chat Completions as the base, and a developer quoted by InfoQ called it “slightly ironic, but practical”. The governance answer is the charter: technical decisions sit with a TSC whose nine initial maintainers come from OpenAI, Databricks, Amazon, Hugging Face, Ollama, OpenRouter and Ramp.

Where the other three new AAIF projects fit

🧩

Agent Skills

Anthropic

An open format for packaging reusable capabilities an agent loads on demand. It sits above the API: what your agent can do, not how it talks to a model.

🏋️

OpenEnv

Hugging Face

A framework and community hub for shareable reinforcement learning environments, used to train and evaluate agents before production.

📦

OpenSandbox

Alibaba

A general-purpose sandbox platform for AI apps, with multi-language SDKs and Docker and Kubernetes runtimes: where your agent’s tools run code.

The other six AAIF projects are A2A, Agent Router, agentgateway, AGENTS.md, goose and MCP. Rule of thumb: Open Responses is the wire format between your agent loop and the model; MCP sits between your agent and its tools.

2. The Minimal Concepts (Only What the Spec Defines)

The spec is short once you skip the examples. Here are the six ideas the tutorial needs, as the 2026-04-24 release and its OpenAPI file define them.

🧱

Items

The atomic unit of context, in both directions. The standard types, keyed by type, are message, function_call, function_call_output and reasoning (plus compaction). Every item has an id, a type and a status, and output items move from in_progress to completed or incomplete. Vendor items must carry a slug prefix, such as openai:web_search_call.

📄

The response object

POST /v1/responses returns a response resource: id, status, model, the output array of items, usage, and an echo of your settings, including store and previous_response_id. A string input counts as one user message. Since 2026-04-24, assistant messages can carry an optional phase (commentary or final_answer).

📡

Streaming events

With stream: true the server sends SSE whose event name matches the JSON type. Events are semantic: state transitions (response.created, response.in_progress, response.completed) and deltas (response.output_item.added, response.output_text.delta, response.function_call_arguments.delta). Every stream must end with response.completed, response.failed or response.incomplete, and a trailing [DONE] is optional.

🛠️

Tools

The only tool type in the core schema is function. Externally hosted tools (your functions) hand control back to you. Internally hosted ones (search, file search) run inside the provider and show up as slug-prefixed tools and items. tool_choice takes auto, required, none, a specific function or an allowed_tools subset.

🔗

previous_response_id and store

previous_response_id makes the server load the earlier response’s input and output and append your new input, in that order. store is a boolean described as “whether to store the response so it can be retrieved later”, yet the spec defines no endpoint to retrieve it. Over WebSocket, a store=false chain lives only in connection-local memory.

🧠

Reasoning items

A reasoning item may carry raw content, an opaque encrypted_content blob that only its provider can read back, and a summary that’s safe to show users. All three are optional. To round-trip encrypted reasoning, the spec’s include enum offers reasoning.encrypted_content.

What the spec does not define

There are exactly two HTTP paths: POST /responses and POST /responses/compact. There’s no GET or DELETE by ID, no conversations API and no tool type beyond function in the core. The 2026-04-24 release added an optional WebSocket transport on the same /v1/responses resource, with one in-flight response per connection and a 60-minute connection limit. Anything else a provider offers is an extension, so treat it as non-portable.

3. Step 1: The Same Request to Seven Providers

You don’t need a new SDK. The official openai SDK already speaks this wire format, so you point base_url at another server. The base URLs and model IDs below come from each provider’s docs. The request sends only the two items the compliance suite’s own system-prompt test sends, so any server that passes that test should accept it.

0. Start the local servers (skip the ones you don’t use)

# Ollama (Responses support since v0.13.3) listens on port 11434
ollama pull qwen3:8b

# LM Studio (Responses support since 0.3.29): load openai/gpt-oss-20b in the app, then
lms server start --port 1234

# vLLM: flags from its Responses tool-calling example, plus an API key (needs a GPU)
vllm serve Qwen/Qwen3-1.7B --reasoning-parser qwen3 \
  --enable-auto-tool-choice --tool-call-parser hermes \
  --api-key token-abc123

1. One client, seven providers (Python)

# pip install --upgrade openai   (signatures checked against openai 3.40.0)
import os
import openai
from openai import OpenAI

# Base URLs and model IDs from each provider's docs (Oct 2026).
# The SDK appends /responses, so every base_url stops at /v1.
PROVIDERS = {
    "openai":      {"base_url": "https://api.openai.com/v1", "key_env": "OPENAI_API_KEY",
                    "model": "gpt-6-astra", "extra": {"store": False}},  # OpenAI stores by default
    "openrouter":  {"base_url": "https://openrouter.ai/api/v1", "key_env": "OPENROUTER_API_KEY",
                    "model": "openai/gpt-6.1-terra"},
    "huggingface": {"base_url": "https://router.huggingface.co/v1", "key_env": "HF_TOKEN",
                    "model": "openai/gpt-oss-120b:groq"},
    "vercel":      {"base_url": "https://ai-gateway.vercel.sh/v1", "key_env": "AI_GATEWAY_API_KEY",
                    "model": "anthropic/claude-sonnet-5.5"},
    "ollama":      {"base_url": "http://localhost:11434/v1", "key": "ollama", "model": "qwen3:8b"},
    "lmstudio":    {"base_url": "http://localhost:1234/v1", "key": "lm-studio",
                    "model": "openai/gpt-oss-20b"},
    "vllm":        {"base_url": "http://localhost:8000/v1", "key": "token-abc123",
                    "model": "Qwen/Qwen3-1.7B"},
}

def client_for(name):
    p = PROVIDERS[name]
    # Never pass api_key=None: the SDK falls back to OPENAI_API_KEY
    # and would send your OpenAI key to whatever base_url you set.
    key = p.get("key") or os.environ[p["key_env"]]
    return OpenAI(base_url=p["base_url"], api_key=key), p["model"], p.get("extra", {})

# The same two items the compliance suite's system-prompt test sends
INPUT = [
    {"type": "message", "role": "system", "content": "Answer in one short sentence."},
    {"type": "message", "role": "user", "content": "What does HTTP status 429 mean?"},
]

for name in PROVIDERS:
    try:
        client, model, extra = client_for(name)
        r = client.responses.create(model=model, input=INPUT, **extra)
        print(f"{name:12} {r.status} {r.output_text!r}")
    except (KeyError, openai.APIError) as e:  # missing key, server down, 4xx/5xx
        print(f"{name:12} skipped: {type(e).__name__}: {e}")

The same call in TypeScript

// npm i openai   (types checked against openai 7.45.1)
import OpenAI from "openai";

// One provider per run: set BASE_URL, API_KEY and MODEL
const client = new OpenAI({
  baseURL: process.env.BASE_URL ?? "http://localhost:11434/v1",
  apiKey: process.env.API_KEY ?? "ollama", // never undefined: it falls back to OPENAI_API_KEY
});

const response = await client.responses.create({
  model: process.env.MODEL ?? "qwen3:8b",
  input: [
    { type: "message", role: "system", content: "Answer in one short sentence." },
    { type: "message", role: "user", content: "What does HTTP status 429 mean?" },
  ],
});

console.log(response.status, response.output_text);

Keep model IDs in config, not in the request

Each provider has its own namespace: gpt-6-astra on OpenAI, provider/model on OpenRouter and Vercel, repo:provider on Hugging Face (or a policy suffix like :fastest or :cheapest), Ollama tags like qwen3:8b, and on vLLM whatever repo you served.

Never pass api_key=None to another provider

In openai 3.40.0, a missing api_key falls back to the OPENAI_API_KEY environment variable whatever base_url you set, and the TypeScript SDK does the same with an undefined apiKey. A helper that does api_key=os.getenv("OPENROUTER_API_KEY") with that variable unset will quietly send your OpenAI key to OpenRouter. That’s why client_for reads os.environ[...] and fails loudly instead. Local servers still need some string: Ollama’s docs call the key “required but ignored”.

4. Step 2: Streaming Without Provider-Specific Parsers

Streaming is where multi-provider clients usually break, because each vendor’s chunks look different. With Open Responses you switch on event.type and ignore what you don’t recognize, as the spec requires clients to do for vendor events.

2. Stream the text and keep the terminal event

client, model, extra = client_for("ollama")  # any provider from step 1

stream = client.responses.create(
    model=model,
    input="Count from 1 to 5, one number per line.",
    stream=True,
    **extra,
)

final = None
for event in stream:
    t = event.type
    if t == "response.output_text.delta":
        print(event.delta, end="", flush=True)
    elif t in ("response.reasoning_summary_text.delta",  # summaries: same name everywhere
               "response.reasoning.delta",               # raw reasoning, spec name
               "response.reasoning_text.delta"):         # raw reasoning, OpenAI SDK name
        pass                                             # route to a "thinking" panel
    elif t == "response.output_item.added" and event.item.type == "function_call":
        print(f"\n[tool call started: {event.item.name}]")
    elif t in ("response.completed", "response.incomplete", "response.failed"):
        final = event.response                           # terminal event: the full response
    elif t == "error":
        raise RuntimeError(f"stream error: {event}")
    # anything else, including vendor events such as acme:trace_event: ignore it

print("\nstatus:", final.status if final else "no terminal event received")

The events you actually need

response.created / response.in_progress
State transitions. Good for a spinner, not for content.
response.output_item.added / .done
An item opens and closes. For a function_call, added already carries the name.
response.output_text.delta / .done
Text deltas for one content part, then the full text.
response.function_call_arguments.delta / .done
Tool arguments as they stream. The .done event has the final JSON string.
response.reasoning_summary_text.delta
Reasoning summary text that’s safe to show users. Same name in the spec and in OpenAI’s SDK.
response.completed / .failed / .incomplete
Terminal events that carry the final response object. Every stream must end with one of them.

One event name differs from OpenAI’s SDK

For raw reasoning text, the spec (both the 2026-01-15 and 2026-04-24 releases) defines response.reasoning.delta and response.reasoning.done, while openai 3.40.0 types the OpenAI events as response.reasoning_text.delta and .done. Ollama’s source emits only the reasoning summary events. If you build a “thinking” panel, match all three names and treat raw reasoning as optional. In the Python SDK, unknown event types keep their original type string, so matching on event.type still works.

5. Step 3: One Function-Call Round Trip

This is the spec’s agentic loop at its smallest. The model emits a function_call item, you run the function, and you send back a function_call_output with the same call_id. I keep the conversation on the client, because that’s the only pattern every provider in this post supports.

3. Stateless round trip (the portable version)

import json

client, model, extra = client_for("vllm")  # swap the name to test another provider

tools = [{
    "type": "function",
    "name": "get_weather",
    "description": "Current temperature in Celsius for a city.",
    "parameters": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"],
        "additionalProperties": False,
    },
}]

def get_weather(city: str) -> str:
    return json.dumps({"city": city, "celsius": 18})  # stub: call a real API here

question = {"type": "message", "role": "user", "content": "What's the weather in Montevideo?"}
history = [question]

first = client.responses.create(model=model, input=history, tools=tools, **extra)

# Keep EVERY output item (reasoning included), then answer each function_call
history += first.output
for item in first.output:
    if item.type == "function_call":
        args = json.loads(item.arguments)
        history.append({
            "type": "function_call_output",
            "call_id": item.call_id,  # must match the call it answers
            "output": get_weather(args["city"]),
        })

second = client.responses.create(model=model, input=history, tools=tools, **extra)
print(second.output_text)

The stateful shortcut, only where the server keeps state

# Only where the server keeps state: OpenAI (store is on by default), LM Studio,
# or vLLM started with VLLM_ENABLE_RESPONSES_API_STORE=1. Not OpenRouter, not Ollama.
first = client.responses.create(model=model, input=[question], tools=tools)
calls = [i for i in first.output if i.type == "function_call"]

second = client.responses.create(
    model=model,
    previous_response_id=first.id,  # the server replays first's input + output
    input=[{"type": "function_call_output", "call_id": c.call_id,
            "output": get_weather(json.loads(c.arguments)["city"])} for c in calls],
    tools=tools,
)

Why I append every output item

OpenAI’s reasoning guide recommends passing back all reasoning, function call and function call output items, and its conversation-state guide appends the whole output array. vLLM’s example appends the function_call item the same way, and Ollama’s input parser accepts reasoning, function_call and function_call_output items. Replaying the full output to the same provider is the most portable loop I found.

Send store=False only where it changes something

OpenAI saves response objects for 30 days by default, so the OpenAI entry in PROVIDERS sends store=False. In that stateless mode, its reasoning items now carry encrypted_content by default. OpenRouter rejects store: true with a 400, vLLM silently downgrades it, and Ollama’s request struct has no store field at all. That’s why the flag lives in per-provider config and not in the shared request.

6. Step 4: Run the Compliance Suite Against Your Endpoint

The repo ships one suite with two front ends: the browser tester at openresponses.org/compliance and a Bun CLI in bin/compliance-test.ts. Both run the same 17 test templates from src/lib/compliance-tests.ts. Run it against your own server or gateway first.

4. Clone, install, run (commands from the README and the CLI’s --help)

# Requires Bun (the repo pins bun 1.4.2 in .tool-versions)
git clone https://github.com/openresponses/openresponses.git
cd openresponses
bun install

# All 17 tests against a local Ollama. --api-key is mandatory; Ollama ignores it
bun run test:compliance --base-url http://localhost:11434/v1 --api-key ollama --model qwen3:8b

# Only the HTTP basics against your gateway (e.g. a LiteLLM proxy), verbose on failure
bun run test:compliance -u http://localhost:4000/v1 -k "$LITELLM_KEY" -m "$MODEL" \
  --filter basic-response,system-prompt,multi-turn,streaming-response,tool-calling -v

# CI: JSON summary on stdout, exit code 1 if any test fails
bun run test:compliance -u "$API_URL" -k "$API_KEY" -m "$MODEL" --json > results.json

What the 17 tests cover

10 HTTP tests (browser and CLI)

  • • basic-response: one user message, validated against the response schema
  • • system-prompt and multi-turn: a system role and assistant history in input
  • • streaming-response: SSE events, their schema and a completed terminal event
  • • tool-calling: a function tool must produce a function_call item
  • • image-input: a user image, so it needs a vision model
  • • assistant-phase: the server accepts commentary and final_answer phases
  • • compact-response and compact-missing-model: POST /responses/compact and its 400/422 rule
  • • response-output-phase-schema: a local schema fixture that sends no request

7 WebSocket tests (CLI only)

  • • websocket-response and websocket-sequential-responses: events over one socket, one turn at a time
  • • websocket-continuation: store=false chaining with previous_response_id on the same socket
  • • websocket-previous-response-not-found and websocket-reconnect-store-false-recovery: the previous_response_not_found error after a missing or lost response
  • • websocket-failed-continuation-evicts-cache: a failed turn evicts the response it referenced
  • • websocket-compact-new-chain: a new chain built from /responses/compact output

Before you use the browser tester

The page calls your endpoint straight from the browser, so the server must allow CORS (LM Studio has lms server start --cors), and you type your API key into a web page. Use a throwaway key with a spend limit, or use the CLI. The browser skips the seven WebSocket tests by design, because browsers can’t set the authorization header on a WebSocket.

What a green run doesn’t prove

The HTTP tests never send previous_response_id, never replay reasoning items and never return a function_call_output: tool-calling only checks that a function_call comes back. store semantics aren’t tested over HTTP either. A pass means your server speaks the core shapes, not that your agent loop will work, which is why step 3 matters. Simon Willison’s other point still stands: there’s no conformance suite for clients.

Use it as a CI gate

The CLI exits with code 1 if any test fails. --json prints a summary with passed, failed and skipped counts, and --filter takes comma-separated test IDs. The key can come from OPENRESPONSES_API_KEY instead of --api-key. Tests run in parallel, and each HTTP request has a 30-second deadline. The default model is gpt-4o-mini, so pass --model for any non-OpenAI endpoint.

7. Provider Differences, According to Their Own Docs

The logo wall on openresponses.org lists 12 organizations: NVIDIA, Vercel, OpenRouter, Hugging Face, LM Studio, Databricks, Red Hat, AWS, Ollama, OpenAI, vLLM and Llama Stack. The January launch coverage named six adopters. A logo isn’t a compliance result, so this table shows what each provider documents today (checked 28 Oct 2026), plus source code where the docs were silent.

ProviderBase URLModel IDsStateStreamingTools
OpenAIhttps://api.openai.com/v1gpt-6-astraStored 30 days by default; previous_response_id; store=false for statelessSSE; WebSocket mode also documentedFunctions plus hosted tools (vendor extensions)
OpenRouterhttps://openrouter.ai/api/v1provider/model, e.g. openai/gpt-6.1-terraStateless only: store: true or previous_response_id returns 400Yes (stream: true)Functions, web search
Hugging Face (beta)https://router.huggingface.co/v1repo:provider, e.g. openai/gpt-oss-120b:groqNot documented: send the full historyYesFunctions, remote MCP, structured outputs
Vercel AI Gatewayhttps://ai-gateway.vercel.sh/v1provider/model, e.g. anthropic/claude-sonnet-5.5Not documented: send the full historyYesFunctions, structured outputs; fallbacks via providerOptions
Ollama (v0.13.3+)http://localhost:11434/v1Local tags, e.g. qwen3:8bStateless only; previous_response_id, conversation and truncation unsupportedYesFunctions; tool_choice isn’t in the supported list
LM Studio (0.3.29+)http://localhost:1234/v1e.g. openai/gpt-oss-20bprevious_response_id supportedYes (SSE)Custom tools, remote MCP (opt-in)
vLLMhttp://localhost:8000/v1The repo you servedstore ignored; previous_response_id returns 404 unless VLLM_ENABLE_RESPONSES_API_STORE=1 (in memory)YesFunctions with --enable-auto-tool-choice and a tool-call parser

“Not documented” means I couldn’t find it in that provider’s Responses docs, not that it fails. None of the pages I read publishes Open Responses compliance results, so run the suite against the endpoints you depend on.

8. Common Errors (and the Fix for Each)

The failure modes to expect when one loop meets different servers, each traced to a doc or a line of source.

Unsupported fields: rejected here, ignored there

Symptom: A field that works on OpenAI gets a 400 on one server and does nothing on another. OpenRouter rejects store: true, vLLM turns store=True into False without telling you, and Ollama’s request struct has no store or previous_response_id field, so its JSON decoder simply drops them.

Fix: Keep the shared request to fields in both the spec and the provider’s supported list, then check the echo: every response object must include store and previous_response_id, and Ollama always echoes false and null, which exposes the drop.

Stateless vs stored responses

Symptom: vLLM answers a previous_response_id with 404 “Response with id … not found” unless you started it with VLLM_ENABLE_RESPONSES_API_STORE=1, which keeps every response in memory and, per its own source comments, never frees them. On Ollama the same request loses context without any error.

Fix: Make client-side history the default and previous_response_id an optimization you turn on per provider. On OpenAI it doesn’t save money either: all previous input tokens in the chain are billed as input.

Reasoning items

Symptom: Tool loops on reasoning models degrade when you drop reasoning items between turns, and an encrypted_content blob from one provider means nothing to another: the spec says its format is provider-specific and opaque.

Fix: Replay every output item to the same provider, untouched. If you switch providers mid-conversation, my rule is to drop reasoning items and keep messages, function calls and their outputs. That’s my inference from the spec, not documented behavior.

Model-name mapping

Symptom: model_not_found or a 404 right after you swap base_url, because gpt-6-astra, openai/gpt-6.1-terra, openai/gpt-oss-120b:groq and qwen3:8b live in different namespaces.

Fix: Store the model ID next to the base URL and the key, as PROVIDERS does. For vLLM, read it from client.models.list(). For Hugging Face, decide whether you want a pinned :provider suffix or a routing policy like :fastest.

Streaming event differences

Symptom: A “thinking” panel stays empty on one provider, or a strict parser crashes on an event it doesn’t know, such as a slug-prefixed vendor event or response.reasoning.delta where it expected response.reasoning_text.delta.

Fix: Switch on event.type, ignore unknown types and take the final state from the terminal event’s response object, not from accumulated deltas. Accept streams that end with or without [DONE]; the spec allows both.

Base URL paths

Symptom: 404s on /v1/v1/responses or on /responses, because tools disagree on what “URL” means. The openai SDK and the compliance CLI both append /responses to a base URL that ends in /v1, while the AI SDK’s @ai-sdk/open-responses provider wants the full …/v1/responses endpoint.

Fix: Store the base URL ending in /v1 and derive the rest. Check it once with the compliance CLI and --filter basic-response before you wire it into an agent.

My rule of thumb

Write the loop against the spec, not against a vendor: stateless history, only the function tool type, events matched by name with a default branch, model IDs in config. Add previous_response_id, hosted tools and vendor events as opt-in extras per provider. That gets you the portability Open Responses promises without betting on the parts each server still does differently.

Conclusion

Moving Open Responses under AAIF matters less for the logo than for the process: a charter, a TSC with maintainers from seven companies, dated immutable spec releases and a test suite anyone can run. The core is small enough to learn in an afternoon: items, semantic events, function tools and an optional previous_response_id.

For the basics, portability is real today: the same request reaches all seven providers here through the SDK you already use. State, reasoning replay and event naming still differ, so keep your loop stateless, run the compliance CLI against every endpoint you depend on, and treat a green run as the floor, not the finish line.

Verification stamp

AAIF: its 22 Oct 2026 announcement and InfoQ’s 26 Oct report. Spec: openresponses/openresponses at commit 8907bc3 (~1.2K stars on 28 Oct 2026). Providers: each one’s docs on 28 Oct 2026, plus Ollama’s openai/responses.go and vLLM’s responses/serving.py and envs.py on main. SDK: openai 3.40.0 (PyPI) and 7.45.1 (npm). I didn’t run any of the code or call any API.

Sources

Diego Rodriguez

Diego Rodriguez

Senior Full-Stack & AI Engineer

Diego has 10+ years of experience building production-grade AI-powered applications, from LLM orchestration and RAG pipelines to ML-driven risk detection and algorithmic trading systems.

Learn more about Diego →