Gateway API Endpoints

Serve OpenAI-compatible and MCP HTTP endpoints from the running gateway

Next

Turn the running gateway into an OpenAI-compatible and MCP endpoint so SDK clients and MCP tools reach the same live agents as chat users.

Diagram

💬 Chat client
WebSocket

🌐 Gateway

🧩 OpenAI SDK
/v1/…

🔌 MCP client
/mcp

🤖 Live agents
& sessions

One process, one agent state, three protocols. This is different from praisonai serve openai, which runs a separate standalone OpenAI-only process.

Quick Start

  1. Enable in gateway.yaml

    Add the api block to your gateway config:

    yaml
    gateway:
      api:
        openai: true
        mcp: true
  2. Start with CLI flags

    Enable the same surfaces from the command line:

    bash
    praisonai gateway start --config gateway.yaml --openai-api --mcp
  3. Enable in Python

    Pass constructor flags to the gateway:

    python
    from praisonai.gateway import WebSocketGateway
    
    WebSocketGateway(config=cfg, openai_api=True, mcp=True).start()

How It Works

Each API request dispatches into the gateway’s own registered agents, sharing the same session store and admission gate as chat users.

Diagram
Live agent + sessionGatewayApiEndpoints/v1/chat/completionsOpenAI SDK clientLive agent + sessionGatewayApiEndpoints/v1/chat/completionsOpenAI SDK clientPOST messagesAuthenticated requestDispatch turnReplyOpenAI-shaped response
StepWhat happens
Resolve agentThe model field selects a registered agent; falls back to the first agent
Reuse sessionEach caller gets a stable session keyed by OpenAI-Session / X-Session-Id header or bearer token
Admit turnThe turn passes through the same admission gate as chat users
RespondThe reply is returned in OpenAI or MCP shape

Endpoints Exposed

SurfaceMethod + PathNotes
OpenAI chatPOST /v1/chat/completionsSSE streaming supported (stream: true); real usage returned; opt-in streamed usage via stream_options.include_usage; opt-in token-level delta.content frames via gateway.api.stream (default off keeps single-chunk buffered behaviour)
OpenAI responses (sync)POST /v1/responsesDefault — blocks for the whole turn; accepts string or messages-style input; returns status: "completed" with usage in Responses-native shape
OpenAI responses (async)POST /v1/responses with background: true or store: trueReturns 202 immediately with status: "queued" and a persisted id
OpenAI response retrieveGET /v1/responses/{id}Fetches the current status + output of a background/stored run
OpenAI response cancelPOST /v1/responses/{id}/cancelStops an in-flight background run cooperatively
OpenAI modelsGET /v1/modelsLists gateway-registered agent IDs
MCP JSON-RPCPOST /mcpMethods: initialize, tools/list, tools/call

Call the OpenAI surface with any standard client:

python
from openai import OpenAI

client = OpenAI(base_url="http://127.0.0.1:8765/v1", api_key="gateway-token")
reply = client.chat.completions.create(
    model="assistant",
    messages=[{"role": "user", "content": "Hello"}],
)
print(reply.choices[0].message.content)

Background Runs

Submit long agent turns in the background, retrieve the result by id, and cancel in-flight runs — over the same OpenAI-compatible surface.

Diagram

Background Response Lifecycle

202 queued

status: completed

status: cancelled

📝 POST /v1/responses
background: true

💾 Response
store

🔍 GET /v1/responses/id

✅ output_text

⏹ POST cancel

The synchronous path is unchanged and remains the default — omit background and store to keep the exact previous behaviour.

Which mode fits

Diagram

Yes

No

Yes

No

Turn takes
< 30 seconds?

POST /v1/responses
omit background/store

Client can
reconnect?

POST /v1/responses
background: true

POST /v1/responses
store: true
keeps result retrievable

Quick Start

  1. Submit in the background
    python
    import requests
    
    resp = requests.post(
        "http://127.0.0.1:8765/v1/responses",
        headers={"Authorization": "Bearer gateway-token"},
        json={"model": "assistant", "input": "Write me a long essay", "background": True},
    )
    run_id = resp.json()["id"]
    print(resp.status_code, resp.json()["status"])   # 202 queued
  2. Poll until terminal
    python
    import time, requests
    
    while True:
        got = requests.get(
            f"http://127.0.0.1:8765/v1/responses/{run_id}",
            headers={"Authorization": "Bearer gateway-token"},
        ).json()
        if got["status"] in ("completed", "cancelled", "failed"):
            break
        time.sleep(2)
    print(got["output_text"])
  3. Cancel if needed
    python
    import requests
    
    requests.post(
        f"http://127.0.0.1:8765/v1/responses/{run_id}/cancel",
        headers={"Authorization": "Bearer gateway-token"},
    )

User Flow

Diagram
Live agentGateway (/v1/responses)App / BackendLive agentGateway (/v1/responses)App / BackendOr cancel:POST /v1/responses {background: true}202 {id: resp-…, status: queued}Spawn asyncio.TaskGET /v1/responses/{id}{status: in_progress}Turn completeGET /v1/responses/{id}{status: completed, output_text: “…”}POST /v1/responses/{id}/cancelInterruptController.request(“cancel”){status: cancelled}

Status Vocabulary

StatusMeaning
queuedSubmitted; the driving task has not started yet (immediately after 202)
in_progressTask is running the agent turn
completedTurn finished; output, output_text, usage are populated
cancelledThe run’s cancel route (or task cancel) fired before completion
failedThe turn raised an exception, or the admission gate rejected it (error.message explains)

A completed response always carries an assistant message (even with empty text), so a client reading output[0] keeps working. Polling the same completed response returns the same output[0].id each time.

Response Object

json
{
  "id": "resp-<hex>",
  "object": "response",
  "created_at": 1727612345,
  "model": "<agent_id>",
  "status": "queued|in_progress|completed|cancelled|failed",
  "output": [
    { "id": "msg-<hex>", "type": "message", "role": "assistant",
      "content": [{"type": "output_text", "text": "..."}] }
  ],
  "output_text": "...",
  "usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0 },
  "error": { "message": "..." }
}

The error block appears only when status is failed. A GET on an unknown id returns 404 with {"error": {"type": "invalid_request_error", "message": "No such response: resp-…"}}.

Both new routes go through the same _api_guarded wrapper as POST /v1/responses (gated by gateway.auth_token) and only mount when gateway.api.openai = true.


Safe Retries (Idempotency-Key)

Send the same Idempotency-Key header on a retry and the gateway returns the first run instead of starting a second — no duplicate LLM spend, no duplicate tool calls.

Diagram

Idempotency-Key Flow

No

Yes, same body

Yes, different body

📨 POST + key: abc

🔍 Key seen?

🤖 Run agent

💾 Store
key → run id

✅ Return result

🔁 Retry + key: abc

⚡ Replay first result

🚫 409 Conflict

Opt-in, backward-compatible: no header → unchanged behaviour (every POST starts a fresh run, as before).

Quick Start

  1. Send a key with /v1/responses

    Any UUID-shaped string works. Reuse the exact same value on every retry of the same logical request.

    python
    import uuid, requests
    
    key = str(uuid.uuid4())
    body = {"model": "assistant", "input": "Write a short poem"}
    headers = {
        "Authorization": "Bearer gateway-token",
        "Idempotency-Key": key,
    }
    
    resp = requests.post("http://127.0.0.1:8765/v1/responses", json=body, headers=headers)
    print(resp.json()["output_text"])
  2. Retry safely with the same key

    If the first POST timed out or your load balancer re-sent it, replay with the same key and body — you’ll get the first run’s answer back, not a second run.

    python
    # Same key + same body → first run's response, no second agent run
    retry = requests.post("http://127.0.0.1:8765/v1/responses", json=body, headers=headers)
    assert retry.json()["id"] == resp.json()["id"]  # same run id
  3. Works on /v1/chat/completions too
    python
    import uuid, requests
    
    key = str(uuid.uuid4())
    body = {
        "model": "assistant",
        "messages": [{"role": "user", "content": "Hello"}],
    }
    headers = {"Authorization": "Bearer gateway-token", "Idempotency-Key": key}
    
    requests.post("http://127.0.0.1:8765/v1/chat/completions", json=body, headers=headers)

What happens on each case

CaseBehaviour
No headerUnchanged — every POST starts a fresh run.
First use of a keyRun is created; the key is recorded with a fingerprint of the request body.
Retry, same key + equivalent bodyThe original run is returned. For /v1/responses background runs, you see the run’s current state (completed with the real output_text once it finishes), not the stale queued snapshot.
Same key + materially different body409 Conflict with error.type: "invalid_request_error" — the gateway refuses to silently honour either request.
Concurrent retry while the first run is still workingThe second POST waits for the first run and gets the same response — not a second run.

User Flow

Diagram
🤖 Live agent🌐 Gateway🔀 Load balancer💻 Client🤖 Live agent🌐 Gateway🔀 Load balancer💻 ClientConnection drops before responsePOST /v1/responsesIdempotency-Key: abcForward (attempt 1)Run turnForward (attempt 2, same key)Replay first run’s response(first run finishes)200 OK — one agent run, one response

Scoping and limits

RuleWhy
Per routeA key reused across /v1/responses and /v1/chat/completions never replays one route’s response shape on the other — a chat.completion is never returned for a response request.
Per callerKeys scoped by bearer token or OpenAI-Session / X-Session-Id header. Two different callers using the same key get independent runs on a shared gateway. Anonymous callers on auth-disabled gateways share one “anon” scope — the client’s own key is the handle.
Streaming excludedstream: true chat completions bypass the idempotency check — streamed responses are not replayable. Use the buffered (non-stream) path when you need safe retries.
In-process, boundedUp to 1024 keys in RAM; evicts only entries whose run is terminal, so a long-running run is never evicted mid-flight. Does not survive a gateway restart.
No request bodies storedThe gateway stores a SHA-256 fingerprint of the canonicalised body plus the public run id/status — never your request body or credentials.

Token Usage

Every response reports real per-turn token counts from the agent’s LLM instance.

Diagram

📨 Request

🤖 Agent turn

📸 Snapshot
last_token_metrics

✅ usage block

Each surface reports usage in its own OpenAI-native shape:

Surfaceusage shape
/v1/chat/completions{prompt_tokens, completion_tokens, total_tokens} (OpenAI Chat Completions)
/v1/responses{input_tokens, output_tokens, total_tokens} (OpenAI Responses-native)

When an agent exposes no LLM metrics, usage falls back to zeros in the correct spec shape, so clients never need to special-case a missing block.

Streamed usage

Opt in with stream_options.include_usage: true to receive a streamed usage chunk — the same contract OpenAI’s own API uses.

python
from openai import OpenAI

client = OpenAI(base_url="http://127.0.0.1:8765/v1", api_key="gateway-token")
stream = client.chat.completions.create(
    model="assistant",
    messages=[{"role": "user", "content": "Hello"}],
    stream=True,
    stream_options={"include_usage": True},
)
for chunk in stream:
    if chunk.usage:
        print(chunk.usage)  # final chunk: real prompt/completion/total tokens

The usage-only chunk is emitted right before data: [DONE]. Without include_usage, the stream frames are unchanged and no extra chunk is sent. This is orthogonal to token-level streaming below — it works with or without it.

Token-level streaming (opt-in)

Enable gateway.api.stream to emit each DELTA_TEXT / FIRST_TOKEN piece as its own chat.completion.chunk with delta.content, so time-to-first-content matches token latency instead of full-turn latency.

Diagram
Agent (stream_emitter)/v1/chat/completionsOpenAI SDKAgent (stream_emitter)/v1/chat/completionsOpenAI SDKPOST stream=truerole preludeFIRST_TOKEN “Hel”delta.content “Hel”DELTA_TEXT “lo “delta.content “lo “DELTA_TEXT “world”delta.content “world”finish_reason=stop[DONE]

Default is off: without opt-in the SSE surface is byte-for-byte the previous single buffered chunk, so existing clients keep working unchanged.

Enable it in gateway.yaml:

yaml
gateway:
  api:
    openai: true
    stream: true   # opt-in token-level SSE on /v1/chat/completions

Or in Python:

python
from praisonai.gateway import WebSocketGateway
from praisonaiagents.gateway import ApiConfig

WebSocketGateway(
    config=cfg,
    api=ApiConfig(openai=True, stream=True),
).start()

Consume it from any standard OpenAI client — no client-side change needed:

python
from openai import OpenAI

client = OpenAI(base_url="http://127.0.0.1:8765/v1", api_key="gateway-token")
stream = client.chat.completions.create(
    model="assistant",
    messages=[{"role": "user", "content": "Write a haiku about the sea"}],
    stream=True,
)
for chunk in stream:
    delta = chunk.choices[0].delta
    if delta.content:
        print(delta.content, end="", flush=True)  # arrives token-by-token

Behaviour guarantees:

GuaranteeWhat it means
Same frame envelopeThe role prelude, finish_reason: "stop" terminator, stream_options.include_usage, and [DONE] sentinel are all preserved.
No-delta fallbackIf the agent emits no token deltas, the buffered reply is still delivered as one delta.content frame — nothing is lost, even for an empty reply.
Reasoning filteredThinking/reasoning deltas are excluded from the answer stream.
Concurrent isolationA per-turn guard ensures two callers sharing one agent never receive each other’s tokens.

Configuration Options

The gateway.api block maps to the ApiConfig dataclass. All three flags are opt-in; when openai and mcp are both False (default), no extra routes are mounted.

OptionTypeDefaultDescription
openaiboolFalseServe /v1/chat/completions, /v1/responses, /v1/models backed by the gateway’s live agents and sessions.
mcpboolFalseServe an MCP JSON-RPC endpoint at /mcp exposing registered agents as callable tools.
streamboolFalseEmit true token-level delta.content frames on /v1/chat/completions SSE by wiring the agent’s stream_emitter through the gateway hot path. Off by default: streaming responses stay byte-for-byte the buffered single-chunk path.

ApiConfig also exposes an enabled property (true if either surface is on), plus to_dict() and from_dict().

Card

Full API surface


How Auth Works

Every API route is protected by the same gateway.auth_token as /info and /metrics.

Diagram

yes

no

📨 Request

🔍 auth_token
valid?

✅ Dispatch to agent

🔒 401

The /info endpoint advertises which surfaces are enabled in its api field, so clients can introspect a running gateway before connecting.

GET /info also returns scopes, the caller’s effective operator scopes on this request, resolved from the bearer token against the auth_scopes policy. With no policy, every authenticated caller gets every scope.

json
{
  "version": "2.3.4",
  "protocol_version": 7,
  "sessions": 7,
  "clients": 3,
  "api": {},
  "scopes": ["read", "write"]
}

praisonai gateway status prints this as an Access: line — see Gateway CLI → status and Operator Scopes.


When to Use vs praisonai serve openai

NeedChoose
SDK clients share chat users’ live sessionsGateway api: block
Standalone OpenAI-only endpointpraisonai serve openai
Expose gateway agents as MCP toolsGateway api.mcp

Best Practices

Keep surfaces off unless needed

Leave openai: false and mcp: false (the defaults) unless a client needs them. Disabled surfaces mount no routes and leave the gateway unchanged.

Protect with auth_token in production

Every /v1/* and /mcp route uses the same gateway.auth_token. Set a strong token when binding to any non-loopback interface.

Pin conversations with a session header

Pass an OpenAI-Session or X-Session-Id header to reuse one agent session across calls. Without it, stateless callers get a fresh session per request.

Introspect surfaces with /info

GET /info returns an api field listing enabled surfaces, so tooling can confirm what a gateway exposes before dispatching.

Enable streamed usage for cost accounting

Pass stream_options={"include_usage": True} when using stream=True if your client tracks cost per response. The extra chunk arrives right before [DONE] and carries real per-turn totals.

Usage is per-turn, even under concurrent sessions

The gateway snapshots the agent’s per-turn token metrics in the same execution context that produced the reply, so a usage field returned to one caller cannot be corrupted by a concurrent turn on the same shared agent. No configuration required.

Run long turns in the background

Any turn that could exceed the client’s HTTP timeout is a candidate. POST /v1/responses with background: true returns a persisted id in ~milliseconds; poll GET /v1/responses/{id} at a comfortable interval (2–5s) until status is terminal. If the caller loses connectivity, the run keeps going and the result is still retrievable by id (subject to the in-process store cap).

Set a bearer token to scope background runs

Owner-scoped retrieval only kicks in when the caller has a stable identity. Anonymous callers on auth-disabled gateways are looked up by id alone. Set gateway.auth_token in any deployment where multiple clients share the same gateway.

Cancel is cooperative

/cancel fires the same interrupt seam as the WebSocket /stop path — the agent checks it at its own checkpoints and stops between LLM calls / tool calls. A tight, non-yielding loop inside a tool won’t stop until it yields; the task-cancel fallback covers most cases but not synchronous CPU-bound work.

Set an Idempotency-Key for safe retries

Any production client should set Idempotency-Key: <uuid> on every POST /v1/responses and POST /v1/chat/completions. The gateway dedupes the retry at the API boundary, so a timeout or load-balancer re-send never starts a second agent run or doubles your LLM spend. Reuse the same key on every retry of the same logical request. Applies to the buffered (non-stream) path only — streaming responses are not replayable.


Related