Skip to content

OpenAI Responses API on your server

POST /v1/responses speaks the OpenAI Responses API: typed input and output items, function calls as items and typed stream events. Code written for the Responses API works with ZenithAI after two changes: the base URL and the model name. Your prompts and answers stay on your own server.

from openai import OpenAI
client = OpenAI(base_url="https://dummydomain/v1", api_key="YOUR_API_KEY")
response = client.responses.create(
model="Fast",
instructions="Answer in two sentences.",
input="Explain why leaves look green.",
)
print(response.output_text)

Use Instant, Fast, Smart or Genius (when your server has it) as the model. See Choose a tier. instructions becomes the system prompt.

The answer is a response object:

Field What it holds
id A resp_... id
status completed, incomplete (the answer reached max_output_tokens) or failed
output A message item with output_text content, and function_call items when the model calls your functions
output_text In the OpenAI libraries, all the text of output joined together
usage input_tokens, output_tokens and total_tokens

The endpoint is stateless: nothing is stored on the server. For a follow-up, send the conversation again in input. Every output item can be sent back exactly as you got it.

history = [{"role": "user", "content": "Suggest one name for a tea shop in Pune. Reply with the name only."}]
first = client.responses.create(model="Fast", input=history)
history += first.output # the answer's items, sent back unchanged
history.append({"role": "user", "content": "Now give it a short Marathi tagline, with its meaning in English."})
second = client.responses.create(model="Fast", input=history)
print(second.output_text)

store: true, previous_response_id and conversation are refused with HTTP 400 and the code unsupported_parameter. Leave store out, or send false.

On tiers whose capabilities.tools is true in /v1/models, the model can ask your code to run a function. A tier without it answers HTTP 400 unsupported_tools. Run it, add the result as a function_call_output item with the same call_id, and send the request again.

import json
tools = [{
"type": "function",
"name": "get_order_status",
"description": "Look up an order by its number",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
"additionalProperties": False,
},
"strict": True,
}]
def get_order_status(order_id):
return {"order_id": order_id, "status": "shipped", "expected": "14-10-2026"}
items = [{"role": "user", "content": "Where is my order SO-4471?"}]
response = client.responses.create(model="Smart", input=items, tools=tools)
for _ in range(5): # cap the rounds
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
break
items += response.output
for call in calls:
result = get_order_status(**json.loads(call.arguments))
items.append({"type": "function_call_output", "call_id": call.call_id, "output": json.dumps(result)})
response = client.responses.create(model="Smart", input=items, tools=tools)
print(response.output_text)

Only function tools are accepted. tool_choice takes auto, none, required or {"type": "function", "name": "..."}. The arguments are checked against your schema before you get them, even when strict is left out. Still check values in your code before you act on them.

A function can hand a picture back to the model. Put input_text and input_image parts in the output of a function_call_output, in the order you want them read. The model looks at the image and answers from it. This is how an agent fetches a scanned bill from your document store, a photo from a site camera or a chart from your reporting tool, and then reasons about what it sees.

import base64, json
from pathlib import Path
tools = [{
"type": "function",
"name": "get_bill_scan",
"description": "Fetch the scanned image of a bill by its id",
"parameters": {
"type": "object",
"properties": {"bill_id": {"type": "string"}},
"required": ["bill_id"],
"additionalProperties": False,
},
"strict": True,
}]
def get_bill_scan(bill_id):
# Your document store goes here. This example reads a file from disk.
data = base64.b64encode(Path("inbox/bill-001.png").read_bytes()).decode()
return [
{"type": "input_text", "text": f"Scan of bill {bill_id}:"},
{"type": "input_image", "image_url": f"data:image/png;base64,{data}"},
]
items = [{"role": "user", "content": "Who is the vendor on bill B-17, and what is the total payable?"}]
response = client.responses.create(model="Fast", input=items, tools=tools)
for _ in range(5): # cap the rounds
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
break
items += response.output
for call in calls:
parts = get_bill_scan(**json.loads(call.arguments))
items.append({"type": "function_call_output", "call_id": call.call_id, "output": parts})
response = client.responses.create(model="Fast", input=items, tools=tools)
print(response.output_text)
  • output is either a string, as before, or a list of input_text and input_image parts. Text and images can be mixed, and their order is kept.
  • Send each image output right after the function_call it answers, in the run of outputs that follows that call, as the loop above does. Otherwise the request is refused with HTTP 400 unpaired_tool_output_image.
  • Images from your functions and images in user messages share one budget: 8 images and 20 MiB per request, 10 MiB per image, inside the tier’s context window. Use a tier whose capabilities.tools and capabilities.vision are both true in /v1/models.
  • It works with streaming, every tool_choice mode and text.format, so the final answer can be JSON that matches your schema.
  • In a request that carries images, the text [img] and [img- is reserved. In ordinary text it is sent with a space added ([ img]). In tool names, descriptions, schemas and call arguments it is refused with HTTP 400 reserved_image_marker.

Set text.format to a JSON schema. The answer in output_text is JSON that matches it. The checks are the same as for response_format; see Structured JSON output.

import json
schema = {
"type": "object",
"properties": {
"invoice_number": {"type": "string"},
"date": {"type": "string"},
"total": {"type": "number"},
},
"required": ["invoice_number", "date", "total"],
"additionalProperties": False,
}
text = "Tax invoice INV-2026-0412 from Sharma Traders dated 03-10-2026. Total payable Rs 1,18,000."
response = client.responses.create(
model="Fast",
instructions="Extract the invoice fields. Keep dates as DD-MM-YYYY.",
input=text,
text={"format": {"type": "json_schema", "name": "invoice", "schema": schema, "strict": True}},
)
print(json.loads(response.output_text))

{"type": "json_object"} asks for any JSON object, and {"type": "text"} is the default.

With stream: true the answer arrives as typed events, numbered by sequence_number. Show the text from response.output_text.delta events. The last event is response.completed (or response.incomplete), which carries the whole response. There is no [DONE] line.

stream = client.responses.create(model="Fast", input="Write a two-line welcome note.", stream=True)
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
elif event.type == "response.failed":
raise RuntimeError(event.response.error.message)
print()

The events come in this order: response.created, response.in_progress, then for each output item response.output_item.added, response.content_part.added, response.output_text.delta (many), response.output_text.done, response.content_part.done and response.output_item.done. A function call sends response.function_call_arguments.delta and .done instead of the text events.

Answers with tools or a text.format are checked in full before anything is sent, so they arrive in one piece after the check.

If a stream fails after text has started, the open message is closed cleanly first: you get response.output_text.done, response.content_part.done and response.output_item.done with the item’s status set to incomplete, and then response.failed. A stream that failed before any text has an empty output.

The OpenAI Agents SDK works with ZenithAI through OpenAIResponsesModel. Turn tracing off: by default the SDK uploads traces of every run to OpenAI.

from agents import Agent, OpenAIResponsesModel, Runner, function_tool, set_tracing_disabled
from openai import AsyncOpenAI
set_tracing_disabled(True) # keep every prompt and answer on your own server
client = AsyncOpenAI(base_url="https://dummydomain/v1", api_key="YOUR_API_KEY")
@function_tool
def get_order_status(order_id: str) -> str:
"""Look up an order by its number."""
return f"Order {order_id} was shipped and is expected on 14-10-2026."
agent = Agent(
name="Support desk",
instructions="Answer customers briefly and politely.",
model=OpenAIResponsesModel(model="Smart", openai_client=client),
tools=[get_order_status],
)
print(Runner.run_sync(agent, "Where is my order SO-4471?").final_output)

LangChain works the same way with ChatOpenAI(base_url=..., api_key=..., model="Smart", use_responses_api=True). Coding agents that need hosted tools or stored responses, such as Codex CLI, are not supported.

Field Notes
model Required. A tier name from /v1/models
input A string, or a list of items: messages (roles system, developer, user, assistant), function_call and function_call_output items. Message parts: input_text, input_image. A function_call_output holds a string or a list of input_text and input_image parts
instructions The system prompt
tools, tool_choice, parallel_tool_calls Function tools only, on tiers with capabilities.tools in /v1/models
text.format text, json_object or json_schema
max_output_tokens The longest answer you want
temperature, top_p As on /v1/chat/completions
stream true for typed events
metadata Up to 16 string values, sent back in the response and not stored

Images go in input_image parts as data: URLs, with the same rules as on /v1/chat/completions. An https image address works only if your administrator has allowed its host; this is off by default, and the server then fetches the image itself, with no redirects.

These are accepted and ignored, so client defaults keep working: store (false or null), user, prompt_cache_key, safety_identifier, max_tool_calls, client_metadata, service_tier (auto or default), truncation: "disabled", an empty include, reasoning (null or {}), background: false, top_logprobs: 0 and text.verbosity: "medium". A reasoning item in input is accepted and dropped.

These are refused with HTTP 400, and param names the field:

Refused Code
store: true, previous_response_id, conversation, background: true, prompt, truncation: "auto", a non-empty include, reasoning options, other service_tier values, context_management unsupported_parameter
Hosted tools such as web_search or file_search unsupported_tool
input_file, input_audio and file_id parts unsupported_content_part
Other item types unsupported_input_item

Any other field is refused as unknown. For web search use POST /websearch, and for PDFs use POST /pdf.

/v1/responses and /v1/chat/completions share the same API keys, tiers, request limits and usage counting. The default limits are:

Limit Default
Requests per API key 60 a minute, across both endpoints
Open streams per API key 4 at a time
Requests from one network address 120 a minute, 40 in any 10 seconds, 16 at the same time

Your administrator can change these numbers. Read each tier’s limits, such as its context window, from /v1/models, as described in Choose a tier.

Errors before the answer starts use the same error object and status codes as OpenAI-compatible chat: message, type, code, param and request_id. A request over a limit gets HTTP 429 with the code rate_limit_exceeded and a Retry-After header in seconds: wait that long and send it again. The OpenAI libraries retry for you. For every field and error code, see the Responses API reference.

Common questions

Does ZenithAI support the OpenAI Responses API?

Yes. POST /v1/responses works with the official OpenAI libraries, the OpenAI Agents SDK and LangChain. Point the client at your server followed by /v1 and use a tier name such as Fast or Smart as the model.

Can I use previous_response_id or store true with ZenithAI?

No. The endpoint is stateless and keeps nothing, so store true and previous_response_id are refused with HTTP 400. Send the whole conversation in input each turn. Every output item can be sent back unchanged.

Should I use /v1/responses or /v1/chat/completions?

Both run on the same engine with the same keys, tiers and limits. Use /v1/responses for new code or for tools built on it, such as the Agents SDK. Keep /v1/chat/completions for code that already uses it.

Does the OpenAI Agents SDK send data to OpenAI when used with ZenithAI?

The SDK uploads traces to OpenAI by default. Call set_tracing_disabled(True) before you run an agent, as the example on this page does, so prompts and answers stay on your own server.

Can my function return an image to the model?

Yes. A function_call_output can hold input_text and input_image parts, so your tool can hand back a scan, a camera photo or a chart and the model reads it before it answers. Use a tier whose capabilities.vision is true.

Why does my server answer 404 for /v1/responses?

Either your server runs an installer older than 4.6.0, which does not have the endpoint, or your administrator turned it off, which answers api_disabled. Update the server with installer 4.6.0, or ask your administrator to turn it on.