Ingram Cloud

Documentation

OpenAI-compatible API

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 tools on the request.
  • Server-side tools — Ingram Cloud calls your MCP server and runs them for you, with approval gating. No tools on 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 the tools.
  • { "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_reasonfinish_reason
max_tokenslength
content_filtercontent_filter
tool_use, pausedtool_calls
everything elsestop

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 destructiveHint pause surfaces as an mcp_approval_request output item (its id is "<run_id>::<tool_call_id>"); you resume by sending an mcp_approval_response input item ({"approve": true}), instead of the Chat Completions tool-call convention.
  • Stateful by design. Pass conversation (a cnv_ id from Conversations) to run inside a first-class, listable conversation; or previous_response_id (a prior run_… id) to continue that run's thread; or the IC-Thread-Id header. Any of them and you send only the new input — 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_call output item: response.output_item.added with status: "in_progress", then response.mcp_call.in_progress, response.mcp_call.completed, and response.output_item.done with status: "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 are mcp_call output items ahead of the assistant message. (Approval-gated tools still ride the mcp_approval_request path above, not mcp_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.
  • background is a create-only mode. Combining it with stream: true, client function tools, or an mcp_approval_response returns 400 unsupported_background: each of those needs the caller present for the turn. A hosted {"type":"file_search"} declaration is fine.