The Gateway can serve a small OpenAI-compatible Chat Completions surface. It is disabled by default.

Once enabled, it serves all of these on the same port as the Gateway (WS + HTTP multiplex):

Method Path
POST /v1/chat/completions
GET /v1/models
GET /v1/models/{id}
POST /v1/embeddings

POST /v1/responses is enabled separately with gateway.http.endpoints.responses.enabled. See OpenResponses API.

Requests run as a normal Gateway agent run (same codepath as apus agent), so routing, permissions, and config match your Gateway.

Enabling the endpoint

{
  gateway: {
    http: {
      endpoints: {
        chatCompletions: { enabled: true },
      },
    },
  },
}

Set enabled: false (or omit it) to disable.

Security boundary (important)

Treat this endpoint as full operator access to the gateway instance:

Auth matrix:

Auth path Behavior
gateway.auth.mode="token" or "password" + Authorization: Bearer ... Proves possession of the shared gateway secret. Ignores any x-apus-scopes header and restores the full default operator scope set: operator.admin, operator.approvals, operator.pairing, operator.read, operator.talk.secrets, operator.write. Treats chat turns as owner-sender turns.
Trusted identity-bearing HTTP (trusted-proxy auth, or gateway.auth.mode="none" on private ingress) Honors x-apus-scopes when present; falls back to the default operator scope set when absent. Loses owner semantics only when the caller explicitly narrows scopes and omits operator.admin. Requires operator.admin for owner-level controls such as x-apus-model.

See Operator scopes, Security, and Remote access.

Authentication

Uses the Gateway auth configuration (see Trusted proxy auth for that mode's details):

Mode How to authenticate
gateway.auth.mode="token" Authorization: Bearer <token>. Set via gateway.auth.token or APUS_GATEWAY_TOKEN.
gateway.auth.mode="password" Authorization: Bearer <password>. Set via gateway.auth.password or APUS_GATEWAY_PASSWORD.
gateway.auth.mode="trusted-proxy" Route through the configured identity-aware proxy; it injects the required identity headers. Same-host loopback proxies need explicit gateway.auth.trustedProxy.allowLoopback = true.
gateway.auth.mode="none" No auth header required (private ingress only).

Notes:

When to use this endpoint

Agent-first model contract

Apus treats the OpenAI model field as an agent target, not a raw provider model id.

model value Routes to
apus Configured default agent
apus/default Configured default agent (stable alias; safe to hardcode even if the real default agent id changes between environments)
apus/<agentId> or apus:<agentId> Specific agent
agent:<agentId> Specific agent (compatibility alias)

Optional request headers:

Header Effect
x-apus-model: <provider/model-or-bare-id> Overrides the backend model for the selected agent. Shared-secret bearer callers can use this directly; identity-bearing callers (trusted-proxy, or private no-auth ingress with x-apus-scopes) need operator.admin, otherwise 403 missing scope: operator.admin.
x-apus-agent-id: <agentId> Compatibility override for agent selection.
x-apus-session-key: <sessionKey> Explicit session routing. Rejected with 400 invalid_request_error if it uses a reserved internal namespace (subagent:, cron:, acp:).
x-apus-message-channel: <channel> Sets the synthetic ingress channel context for channel-aware prompts/policies.

/v1/models lists top-level agent targets (apus, apus/default, apus/<agentId>), not backend provider models and not sub-agents; sub-agents stay internal execution topology. If you omit x-apus-model, the selected agent runs with its normal configured model.

The model list and detail endpoints require operator.read or a scope that includes it because they expose the global agent target inventory.

/v1/embeddings uses the same agent-target model ids. Send x-apus-model (from a shared-secret caller, or an identity-bearing caller with operator.admin) to pick a specific embedding model; otherwise the request uses the selected agent's normal embedding setup.

Session behavior

By default the endpoint is stateless per request (a new session key is generated each call).

If the request includes an OpenAI user string, the Gateway derives a stable session key from it so repeated calls can share an agent session. For custom apps, reuse the same user value per conversation thread; avoid account-level identifiers unless you want multiple conversations/devices to share one Apus session. Use x-apus-session-key only when you need explicit routing control across multiple clients/threads, with application-owned keys that avoid the reserved namespaces above.

Explicit incognito session continuation

Explicitly selecting or continuing an incognito conversation with x-apus-session-key (the sessionKey override) requires effective operator.admin authority. This rule follows authority, not ingress: it denies both trusted-proxy callers without owner/admin authority and private gateway.auth.mode="none" callers that explicitly narrow x-apus-scopes below admin (for example, to operator.write). Either receives HTTP 403 with a forbidden error. A profile-less private no-auth caller on this path gets missing scope: operator.admin; for a profile-backed caller, the response hides the private target with this error shape (where <sessionKey> is the requested override):

{
  "error": {
    "message": "Incognito session \"<sessionKey>\" was not found.",
    "type": "forbidden"
  }
}

Owner/admin callers keep explicit incognito session continuation. A private no-auth request without x-apus-scopes receives the default operator scopes, including operator.admin, and is therefore treated as owner/admin. Reserved internal namespace overrides (subagent:, cron:, acp:) remain a separate validation failure and still return HTTP 400 with invalid_request_error.

Request limits

The endpoint uses built-in limits of 20 MB per request body, 8 image_url parts from the latest user message, and 20 MB of cumulative decoded image data. Image source policy remains configurable under gateway.http.endpoints.chatCompletions.images:

{
  gateway: {
    http: {
      endpoints: {
        chatCompletions: {
          enabled: true,
          images: {
            allowUrl: false,
            urlAllowlist: ["cdn.example.com", "*.assets.example.com"],
            allowedMimes: [
              "image/jpeg",
              "image/png",
              "image/gif",
              "image/webp",
              "image/heic",
              "image/heif",
            ],
            maxBytes: 10485760,
            maxRedirects: 3,
            timeoutMs: 10000,
          },
        },
      },
    },
  },
}

Image settings default to:

Key Default
images.allowUrl false (URL-sourced image_url parts are rejected unless enabled)
images.maxBytes 10MB per image
images.maxRedirects 3
images.timeoutMs 10s

HEIC/HEIF image_url sources are accepted and normalized to JPEG before provider delivery through the shared Apus image processor (Rastermill), which falls back to a system converter (sips, ImageMagick, GraphicsMagick, or ffmpeg) for formats needing external codec support.

Security note: allowlisting a hostname does not bypass private/internal IP blocking. For internet-exposed gateways, apply network egress controls in addition to app-level guards. See Security.

Chat tool contract

/v1/chat/completions supports a function-tool subset compatible with common OpenAI Chat clients.

Supported request fields

Field Notes
tools Array of { "type": "function", "function": { ... } }
tool_choice "auto", "none", "required", or { "type": "function", "function": { "name": "..." } }
messages[*].role: "tool" Follow-up turns
messages[*].tool_call_id Binds a tool result back to a prior tool call
max_completion_tokens Positive safe integer; per-call cap on total completion tokens (reasoning tokens included). Current field name; used when both fields are non-null. Null or omitted leaves it unset.
max_tokens Positive safe integer; legacy alias. It is still validated when max_completion_tokens is non-null, then ignored for precedence. Null or omitted leaves it unset.
temperature Number 0-2; best-effort, forwarded to the upstream provider. 400 invalid_request_error if out of range.
top_p Number 0-1; best-effort. 400 invalid_request_error if out of range.
frequency_penalty Number -2.0 to 2.0; best-effort. 400 invalid_request_error if out of range.
presence_penalty Number -2.0 to 2.0; best-effort. 400 invalid_request_error if out of range.
seed Integer; best-effort. 400 invalid_request_error for non-integer values.
stop String or array of up to 4 strings; best-effort. 400 invalid_request_error for more than 4 sequences or non-string/empty entries.

All sampling and token-cap fields ride the same agent stream-param channel and are forwarded best-effort:

Unsupported variants

Returns 400 invalid_request_error for:

For tool_choice: "required" and function-pinned tool_choice, the endpoint narrows the exposed client function-tool set, instructs the runtime to call a client tool before responding, and errors if the agent response has no matching structured client-tool call. This applies to the caller-supplied HTTP tools list, not every internal Apus agent tool.

Non-streaming tool response shape

When the agent calls tools, the response uses:

Streaming tool response shape

When stream: true, tool calls arrive as incremental SSE chunks: an initial assistant role delta, optional assistant commentary deltas, one or more delta.tool_calls chunks carrying tool identity and argument fragments, then a final chunk with finish_reason: "tool_calls" and data: [DONE].

For required or function-pinned tool choices, prose is held until the matching call is confirmed. When the run returns finalized prose, the stream uses that text instead of provisional deltas.

If stream_options.include_usage=true, a trailing usage chunk is emitted before [DONE].

Tool follow-up loop

After receiving tool_calls, execute the requested function(s) and send a follow-up request that includes the prior assistant tool-call message plus one or more role: "tool" messages with matching tool_call_id. This continues the same agent reasoning loop to produce the final answer.

If a tool produces no text, still include its result with content: "" or an empty text-part array. An empty result completes the call; omitting the result does not. Legacy role: "function" results may use content: null with their function name.

Streaming (SSE)

Streaming preserves repeated content from separate assistant messages. If a correction cannot be represented by appending to text already sent, the stream reports an error rather than completing with inconsistent content.

Set stream: true to receive Server-Sent Events:

Failed agent runs, including whole-agent timeouts, return an error instead of a successful completion. A streaming failure emits an error object followed by [DONE]; partial content may already have reached the client. Timeout settings follow the agent loop.

Disconnecting the HTTP client cancels active source-URL downloads and the agent run. If cancellation happens while preparing input, the Gateway releases that download and does not start another input download or the agent run. This applies to both streaming and non-streaming requests. If the Gateway itself cancels the agent run while the client is still connected, the request fails with 500 api_error and the message agent run was cancelled, not as a provider timeout.

Open WebUI quick setup

Expected behavior: GET /v1/models lists apus/default, and Open WebUI uses it as the chat model id. For a specific backend provider/model, set the agent's normal default model, or send x-apus-model (shared-secret caller, or identity-bearing caller with operator.admin).

Quick smoke test:

curl -sS http://127.0.0.1:18789/v1/models \
  -H 'Authorization: Bearer YOUR_TOKEN'

If that returns apus/default, most Open WebUI setups can connect with the same base URL and token.

Examples

Stable session for one app conversation:

curl -sS http://127.0.0.1:18789/v1/chat/completions \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "apus/default",
    "user": "conv:YOUR_CONVERSATION_ID",
    "messages": [{"role":"user","content":"Summarize my tasks for today"}]
  }'

Reuse the same user value on later calls for that conversation to continue the same agent session.

Non-streaming:

curl -sS http://127.0.0.1:18789/v1/chat/completions \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "apus/default",
    "messages": [{"role":"user","content":"hi"}]
  }'

Streaming:

curl -N http://127.0.0.1:18789/v1/chat/completions \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'x-apus-model: openai/gpt-5.4' \
  -d '{
    "model": "apus/research",
    "stream": true,
    "messages": [{"role":"user","content":"hi"}]
  }'

List models:

curl -sS http://127.0.0.1:18789/v1/models \
  -H 'Authorization: Bearer YOUR_TOKEN'

Fetch one model:

curl -sS http://127.0.0.1:18789/v1/models/apus%2Fdefault \
  -H 'Authorization: Bearer YOUR_TOKEN'

Create embeddings:

curl -sS http://127.0.0.1:18789/v1/embeddings \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'x-apus-model: openai/text-embedding-3-small' \
  -d '{
    "model": "apus/default",
    "input": ["alpha", "beta"]
  }'

/v1/embeddings supports input as a string or array of strings.

Provider failures use the same error mapping as chat completions: missing provider credentials return 401 authentication_error with setup guidance, unknown provider models return 404 invalid_request_error, and provider overload returns 503 api_error. Credential values are redacted. Unexpected failures return 500 api_error with internal error.

For models that support it, a positive integer dimensions requests the output vector size. It overrides the selected agent's active memory.search.outputDimensionality and also applies when memory search is disabled. Omitting it keeps the configured or provider default size.

Related