OpenAI-compatible API
POST /v1/chat/completions drives a smith behind the OpenAI Chat
Completions wire format. Point the openai SDK or the Vercel AI SDK's
@ai-sdk/openai-compatible provider at Ingram Cloud and a logged-in web session
streams straight from the smith: no custom transport, no bespoke SSE parser.
It is the same engine as Runs & streaming: the same smith, the
same MCP tools, the same memory, the same
approval gates and usage metering. Only the bytes on the wire change. Use this
surface for in-app web chat; use /v1/smiths/{sid}/runs when you want the
native envelope, structured output, or server-to-server calls.
Building an in-app chat tab with the Vercel AI SDK? The
AI SDK adapter (@ingram-cloud/ai-sdk) wraps this surface
with the identity, memory, and approval helpers wired in — same
standard wire format, less boilerplate. This page is the wire reference beneath it.
Identity: the token is the smith
There is no smith id in the path. A smith token (sub = "<tenant>:<smith>")
already names exactly one smith, so the call runs as that smith, the same trust
model as an MCP tools/call. The agent is the one that smith runs — resolved
from the smith, never from a request field.
The model field is the literal OpenAI thing: the upstream inference LLM.
Omit it (or send "") to use the agent's configured model; send a model id like
openai.gpt-5.6-sol to override the LLM for that one call (instructions, tools, and memory
still come from the smith's agent). It does not select the agent — the agent
is the one the smith runs.
A model the upstream provider rejects (an unknown id, a typo, an agent slug)
is a real error, not an empty answer. A non-streaming call returns a non-2xx with
the standard { "error": { … } } body; a streaming call emits a terminal
data: { "error": { … } } frame before data: [DONE] (the same convention the
openai SDK and @ai-sdk/openai-compatible raise as an exception). You will
never get a 200 with finish_reason: "stop" and empty content for a failed
turn.
// apiKey: a per-smith token (never a tenant-admin token from the browser)
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamText } from "ai";
const ingram = createOpenAICompatible({
name: "ingram",
baseURL: "https://api.cloud.ingram.tech/v1",
apiKey: SMITH_TOKEN,
});
// "" → the smith's agent's configured model. Use "openai.gpt-5.6-sol" etc. to override the LLM.
const result = streamText({ model: ingram(""), prompt });
for await (const delta of result.textStream) process.stdout.write(delta);
Calling server-side with a tenant-admin token? Name the smith one of two
ways: the OpenAI-standard user field set to the smith's external_id (your own
user id), or an IC-Smith-Id: smt_… header carrying the smt_ id. The user
field is the zero-custom-header path — a stock OpenAI client just works. Without a
resolvable smith the call returns 400 smith_unresolved.
Add an IC-Agent-Id: agt_… header and the user resolution becomes
provisioning: the smith for that (external_id, agent) pair is lazily created on
first touch — the same idempotent semantics as POST /v1/smiths (404 for an
unknown agent, 409 agent_unpublished before its first published version). Apps
that bring their own auth need no provisioning step: send every request with
user + IC-Agent-Id and the smith exists by the time the turn runs. The first
call for a new pair pays the provisioning cost inline, so expect a few extra
seconds of time-to-first-token on that turn only.
# Authorization: smith token (browser-safe, scoped to one smith)
curl https://api.cloud.ingram.tech/v1/chat/completions \
-H "Authorization: Bearer $IC_SMITH_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{ "model": "", "stream": true,
"messages": [{ "role": "user", "content": "What did I spend on travel in May?" }] }'
The stream is standard Chat Completions framing: data: {chunk}\n\n chunks whose
text rides on choices[0].delta.content, terminated by data: [DONE]. The
chunk id is the Ingram Cloud run_… id, so a stream and a
run.completed webhook for the same turn are correlatable, and a
dropped stream can be reconciled against the run record.
Memory: stateless by default, stateful on request
Plain Chat Completions is stateless, so by default the messages you send are
the whole context for that turn and a fresh thread is used. To use Ingram Cloud's
server-side memory instead, send an IC-Thread-Id: <your-id>
header: Ingram Cloud then holds the thread and you send only the new user turn
(the same thread model as a native run's thread_id).
Either way a smith runs one turn at a time:
concurrent calls naming the same smith return 400 conversation_locked, because
they share one memory doc. If you want a smith to serve many requests at once,
turn auto_memory off — memory is what forces the serialization.
Instructions: per-request system is honored
The smith's agent provides the base instructions. A system message (Chat) or the
instructions field (Responses) is appended to them for that one turn — so the
agent's persona and guardrails stay authoritative and you add live, per-request
context (the page the user is on, a read/write toggle, anything recomputed each
call). Omit it and behaviour is unchanged. This is the standard channel for dynamic
context — use it instead of rewriting agent config per turn.
Bringing your own prompt — set per request, versioned with your app? Leave the
agent's instructions empty: appending to nothing makes your system message
the whole prompt.
Reasoning effort
How hard the model thinks is a per-request dial, in the standard OpenAI place:
the reasoning_effort field (Chat) or reasoning: { "effort": … } (Responses),
one of none, minimal, low, medium, high, xhigh:
# Authorization: smith token (browser-safe, scoped to one smith)
curl https://api.cloud.ingram.tech/v1/chat/completions \
-H "Authorization: Bearer $IC_SMITH_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{ "model": "", "reasoning_effort": "high",
"messages": [{ "role": "user", "content": "Reconcile May against the bank export." }] }'
It applies whatever model runs the turn — including the agent's configured
default: on a claude-* model the tier becomes Anthropic adaptive thinking at
the matching effort, on Gemini the matching thinking level
(Models has the mapping). An unknown value is
400 invalid_reasoning_effort; a tier the upstream model rejects surfaces as
that provider's error.
Reasoning output
A model that reasons returns its reasoning summary — what the provider exposes, never raw chain-of-thought — alongside the answer.
On Responses it is a standard reasoning output item, one per block, in the
order the turn produced them:
{ "type": "reasoning", "id": "rs_…",
"summary": [{ "type": "summary_text", "text": "Checking the May export first." }] }
Streaming, a block arrives as response.output_item.added (the reasoning
item), response.reasoning_summary_part.added, one or more
response.reasoning_summary_text.delta, then
response.reasoning_summary_part.done and response.output_item.done.
@ingram-cloud/ai-sdk surfaces these as the AI SDK's own
reasoning parts.
On Chat Completions the same text rides delta.reasoning_content, the field
that surface already uses for it.
A turn that reasons, calls a tool, then reasons again produces two reasoning
items with the mcp_call between them — the run's real order, not a
reconstruction. Retrieving the response later (GET /v1/responses/{id}) returns
the same items, so a background run reports its
reasoning too.
Reasoning summaries are recorded on the run's own timeline
(GET /v1/smiths/{sid}/runs/{rid}/events). They are deliberately not sent to
webhooks or the tenant event feed.
Models that reason invisibly return no reasoning items. There is nothing to
configure — reasoning_effort controls how hard the model
thinks; this is what it reports back.
Images and files
Image and file content parts are passed through to the smith's model. Send the
standard OpenAI shape — a content array mixing text with image_url (Chat) /
input_image (Responses) for images, or file (Chat) / input_file (Responses)
for documents like PDFs. An image may be a hosted https URL or an inline base64
data URL; a file is inlined as base64:
{ "messages": [{ "role": "user", "content": [
{ "type": "text", "text": "What's wrong with my invoice?" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0KGgo…" } },
{ "type": "file", "file": {
"filename": "invoice.pdf",
"file_data": "data:application/pdf;base64,JVBERi0…" } }
] }] }
The smith's model must be vision/document-capable (most current models are). The same content shape works whichever provider backs the model — each receives the image or document in its native form.
Inline files are stored for auditability. Bytes you inline (an image data URL
or a file's file_data) are saved to file storage rather than kept in the run
record, which holds a lightweight reference instead; the run's input then carries
a file_id. Fetch a stored file's metadata at GET /v1/files/{file_id} and its
bytes at GET /v1/files/{file_id}/content (token needs the files:read scope). A
hosted https image URL is passed by reference and not stored. These inline files
are reachable by id or through the run that referenced them; they are not enumerated
by a list endpoint.
Inline bytes are bounded by a 32 MB request limit (matching the tightest common
provider); a larger body is rejected with 413 payload_too_large. For bigger files,
host them and pass an https image URL, or wait for the Files API.
Structured outputs
response_format with a json_schema is honored end-to-end: the answer is
validated against your schema before it returns. You get conforming JSON or an
error, never a best-effort guess:
# Authorization: smith token (browser-safe, scoped to one smith)
curl https://api.cloud.ingram.tech/v1/chat/completions \
-H "Authorization: Bearer $IC_SMITH_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{
"model": "",
"messages": [{ "role": "user", "content": "Invoice #A-1, total 100 EUR." }],
"response_format": { "type": "json_schema", "json_schema": {
"name": "invoice", "strict": true,
"schema": { "type": "object", "additionalProperties": false,
"properties": {
"invoice_number": { "type": ["string", "null"] },
"total": { "type": ["number", "null"] } },
"required": ["invoice_number", "total"] } } }
}'
choices[0].message.content is the JSON document (the run record stores it with
content_type: "application/json"). On Responses the same declaration is the flat
text.format. stream: true keeps its contract — the document arrives as a
single content chunk on Chat Completions, and on Responses as one
response.output_text.delta inside the usual message-item frames, so a stock
client reads it exactly like an incremental turn.
Alone — no tools, no IC-Thread-Id, on Responses no
conversation/previous_response_id, and against an agent that has no tools of
its own — the request is a schema'd one-shot: a direct model call with no tools
and no memory, where your messages are the whole context. It is the cheapest
shape and the right one for classify/extract/route.
With tools, a thread, or an agent that has tools, the turn keeps them: the model may still call your
tools (a tool-calling round returns tool_calls as usual, unconstrained by the
schema), the thread's memory still applies, and the schema is enforced on the
final message only. So an agent can do the work and still answer in a shape your
code parses, without a second call to reshape its prose.
response_format: {"type": "json_object"} is still a 400 — declare a schema
instead. The schema rules and error contract are the same as the native
surface: every object sets "additionalProperties": false and lists
all keys in "required", a provider-rejected schema is 422 schema_error, and a
model that can't satisfy a valid schema is 500 structured_output_failed.
Tools
Two models, both standard, pick per use case:
- Client-side tools — you define functions and run them yourself (the standard
OpenAI function-call loop). Send
toolson the request. - Server-side tools — Ingram Cloud calls your MCP server and runs
them for you, with approval gating. No
toolson the request; register the server once.
Client-side tools (you execute)
Send tools exactly as you would to OpenAI. The model's calls come back as
tool_calls for you to execute; send the results back as tool messages and
the model continues. Ingram Cloud runs nothing and gates nothing — your loop owns
both — and by default it's stateless, so re-send the conversation each turn (same
as OpenAI):
// 1) you send tools + the turn; the model asks to call one
{ "model": "", "tools": [
{ "type": "function", "function": { "name": "get_weather",
"parameters": { "type": "object", "additionalProperties": false,
"properties": { "city": { "type": "string" } }, "required": ["city"] } } } ],
"messages": [ { "role": "user", "content": "weather in Paris?" } ] }
// → finish_reason "tool_calls", message.tool_calls = [{ id, function:{ name, arguments } }]
// 2) you run get_weather, then re-send the whole conversation with the result
{ "model": "", "tools": [ /* same tools */ ],
"messages": [
{ "role": "user", "content": "weather in Paris?" },
{ "role": "assistant", "tool_calls": [ { "id": "call_1", "type": "function",
"function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" } } ] },
{ "role": "tool", "tool_call_id": "call_1", "content": "{\"tempC\":21}" } ] }
// → the model answers with finish_reason "stop"
The loop composes with server-side memory:
send IC-Thread-Id and Ingram Cloud replays the thread's prior turns — including
the tool-call linkage — so you send only the new turn, and the runs land on the
thread (a cnv_ id fills its conversation transcript). The
re-sent messages of the running turn are recorded once, not once per tool round.
A turn that sends tools uses only those client tools — the smith's agent still
provides the system instructions, but its server-side MCP tools sit out that turn
(you brought your own loop). To use MCP tools, omit tools.
With stream: true the tool call streams as it is generated, standard Chat
Completions framing: the first choices[0].delta.tool_calls chunk carries the call's
index, id, type, and function.name with empty arguments; each following
chunk carries the next function.arguments fragment. Concatenate the fragments in
order for the complete JSON — a stock OpenAI client does this for you. The turn ends
with finish_reason: "tool_calls". Reasoning tokens, when the model emits them,
stream on choices[0].delta.reasoning_content ahead of the answer or the call. (The
non-streaming call returns the same tool call whole in message.tool_calls.)
Forcing a tool with tool_choice
Send tool_choice alongside tools to control whether the model may call them,
exactly as you would to OpenAI:
"auto"(the default) — the model decides."none"— the model must answer in text and call nothing."required"— the model must call one of thetools.{ "type": "function", "function": { "name": "get_weather" } }— the model must call that specific tool. On the Responses API the shape is the flatter{ "type": "function", "name": "get_weather" }.
tool_choice only governs the tools you sent on the request, so it is an error to
send it without a non-empty tools array, or to name a tool that isn't in it — both
return 400 invalid_tool_choice (param: "tool_choice") rather than being silently
ignored.
// Authorization: per-smith token
{ "model": "", "tools": [ /* get_weather as above */ ],
"tool_choice": { "type": "function", "function": { "name": "get_weather" } },
"messages": [ { "role": "user", "content": "weather in Paris?" } ] }
// → the model is forced to call get_weather; finish_reason "tool_calls"
Server-side tools (MCP) and approvals
Read/automatic tools run server-side and never appear in this (Chat
Completions) stream: the person just sees assistant text, exactly as the run loop
calls them for you. (If you want a live "tool is running" indicator, the
Responses API surfaces each server-executed call
as an mcp_call item with in_progress/completed lifecycle events.)
A tool marked destructiveHint pauses the run for approval. In this surface the
pause is projected into the tool-call channel: you receive a
choices[0].delta.tool_calls entry naming the real tool and its arguments, and
the turn ends with finish_reason: "tool_calls". The call id is
"<run_id>::<tool_call_id>" so you know which run to resume.
You resume by sending the decision back as the next turn's tool message: the
tool_call_id echoes the id you received, and the content is approve or
reject:
// next request body, staying inside the standard tool-call channel
{ "model": "", "stream": true, "messages": [
{ "role": "user", "content": "Delete the May draft." },
{ "role": "assistant", "tool_calls": [
{ "id": "run_abc::tc_1", "type": "function",
"function": { "name": "delete_page", "arguments": "{\"id\":\"p1\"}" } } ] },
{ "role": "tool", "tool_call_id": "run_abc::tc_1", "content": "approve" }
] }
On approve, Ingram Cloud executes the tool itself (your MCP server, the host
loop, you never run it) and streams the continuation. On reject, the run
completes with stop_reason: "approval_rejected" and nothing is executed. This
maps onto the same approval that a deployment confirmation or /submit approval_decision drives: they are one mechanism, three front doors.
Usage
Set stream_options: { include_usage: true } (the Vercel AI SDK does this for
you) and the final chunk carries usage in the standard place
(prompt_tokens / completion_tokens / total_tokens). Closing the HTTP
connection stops the stream, but the run continues server-side to completion —
every chunk carries the run's run_… id, so read the run record (or its
event log) for the final result. To stop a run mid-flight, submit a
cancel via the native /submit endpoint.
Non-streaming
Omit stream (or set it false) and you get a standard chat.completion object
with choices[0].message, finish_reason, and usage. An approval pause comes
back as finish_reason: "tool_calls" with the same tool_calls shape; resume it
exactly as above.
finish_reason
The run's stop_reason maps onto the standard
values, so the branch your client already has works here:
stop_reason | finish_reason |
|---|---|
max_tokens | length |
content_filter | content_filter |
tool_use, paused | tool_calls |
| everything else | stop |
length means the answer was cut off at the model's output limit; the partial
text is in message.content.
On POST /v1/responses the same two cases end the response as incomplete
rather than completed:
{ "status": "incomplete", "incomplete_details": { "reason": "max_output_tokens" } }
Streaming, that arrives as a response.incomplete event in place of
response.completed. Both are terminal — wait on either, or your client hangs on
a truncated turn. @ingram-cloud/ai-sdk already does.
Chat Completions vs Responses
This page is the Chat Completions projection: the widest-supported format.
Ingram Cloud also speaks the newer Responses API at POST /v1/responses —
same smith, memory, tools, and approvals, and what
@ingram-cloud/ai-sdk rides — with three advantages for
agentic chat:
- Approvals are first-class. A
destructiveHintpause surfaces as anmcp_approval_requestoutput item (itsidis"<run_id>::<tool_call_id>"); you resume by sending anmcp_approval_responseinput item ({"approve": true}), instead of the Chat Completions tool-call convention. - Stateful by design. Pass
conversation(acnv_id from Conversations) to run inside a first-class, listable conversation; orprevious_response_id(a priorrun_…id) to continue that run's thread; or theIC-Thread-Idheader. Any of them and you send only the newinput— Ingram Cloud replays the thread's prior turns for you. This is the conversation transcript, held independently of memory (a conversation works whether or not the smith has any). - Server-tool activity is visible. A tool the run loop executes for you
(your MCP server) streams as an
mcp_calloutput item:response.output_item.addedwithstatus: "in_progress", thenresponse.mcp_call.in_progress,response.mcp_call.completed, andresponse.output_item.donewithstatus: "completed". That drives a live "tool is running" indicator in an in-app chat — the thing Chat Completions deliberately hides. A non-streaming call carries the same information: the run's completed calls aremcp_calloutput items ahead of the assistant message. (Approval-gated tools still ride themcp_approval_requestpath above, notmcp_call.)
Client-side tools work the same on both surfaces; on Responses the model's calls
come back as function_call output items and you send results back as
function_call_output input items (the standard Responses contract), instead of the
Chat Completions tool_calls/tool-message convention. With stream: true the
turn streams incrementally here too: text as response.output_text.delta, and each
call as its own function_call item — response.output_item.added with
status: "in_progress", standard response.function_call_arguments.delta
fragments, then response.output_item.done with status: "completed" and the full
arguments. The client-tool loop is
stateful too: with previous_response_id (or conversation, or IC-Thread-Id) you
send only the new function_call_outputs — Ingram Cloud replays the prior
function_call items, so you needn't echo them back. This holds for parallel calls
and across as many rounds as the turn takes.
# Authorization: smith token (browser-safe, scoped to one smith)
curl https://api.cloud.ingram.tech/v1/responses \
-H "Authorization: Bearer $IC_SMITH_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{ "model": "", "input": "What did I spend on travel in May?" }'
Identity is resolved exactly as above (smith token, the user field, or
IC-Smith-Id), the standard instructions field and any system/developer
input items append to the agent's instructions for the turn (the same channel
as Chat Completions' system messages), and the response id is the IC
run_… id, so it correlates with a run.completed webhook
just like Chat Completions.
Background responses
A long agentic turn shouldn't depend on one HTTP connection staying up. Send
background: true and POST /v1/responses answers immediately with the response
id and status: "queued", then drives the turn server-side:
# Authorization: smith token (browser-safe, scoped to one smith)
curl https://api.cloud.ingram.tech/v1/responses \
-H "Authorization: Bearer $IC_SMITH_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{ "model": "", "input": "Reconcile last quarter.", "background": true }'
# Authorization: smith token (browser-safe, scoped to one smith)
curl https://api.cloud.ingram.tech/v1/responses/run_abc123 \
-H "Authorization: Bearer $IC_SMITH_TOKEN" \
-H "IC-Api-Version: 2026-05-01"
GET /v1/responses/{id} returns the same response object a synchronous call
would, in whatever state the run is in: queued before it starts,
in_progress while it runs, then completed — or incomplete, failed,
cancelled. Polling is not the only option: the id is the run id, so a
run.completed webhook tells you when to fetch, and the run's
event log streams the same turn as it is recorded.
POST /v1/responses/{id}/cancel ends the run and stops its model stream. It is
defined from any state, so cancelling a response that already finished is not an
error — it answers the response as it stands.
If the worker dies, the turn restarts. A background run whose server is lost
mid-turn — a deploy, a crashed replica — goes back on the queue under the same
response id, up to five attempts. That holds only while the run has invoked no
tool: once a tool has run, an effect may have committed (a message sent, a card
charged), so the run ends failed rather than repeat it. A turn that only
reasons and answers is therefore restarted; one that has begun acting is not.
Two limits worth knowing:
- One at a time per smith. A smith with a background run in flight rejects a
second run with
409 conversation_locked— its memory and threads are single-writer. Wait for the first, or cancel it. backgroundis a create-only mode. Combining it withstream: true, client functiontools, or anmcp_approval_responsereturns400 unsupported_background: each of those needs the caller present for the turn. A hosted{"type":"file_search"}declaration is fine.