# @mono-agent/openai-api-adapter

Expose an agent host through the supported OpenAI Chat Completions subset used by
Open WebUI and other compatible HTTP clients.

## Category

<!-- package-metadata:start -->
<!-- Generated by scripts/generate-package-docs.mjs. Do not edit by hand. -->

Category: `communication`
Tier: `core`
Catalog responsibility: Exposes agent responders through OpenAI-compatible model discovery and Chat Completions endpoints.

<!-- package-metadata:end -->

## Responsibility

OpenAI-compatible **Chat Completions subset** for agent hosts. It starts a small
HTTP server, exposes model discovery for Open WebUI, maps supported chat requests
into structural `AgentResponder` calls, and returns OpenAI-shaped JSON or
Server-Sent Event (SSE) responses. It is not a general OpenAI API emulator.

## Install / Usage

### Config-first host

`@mono-agent/agent-app` already includes this adapter. Enable the surface in
`mono-agent.config.json`; no separate package install is needed:

```json
{
  "openaiApi": {
    "enabled": true,
    "host": "127.0.0.1",
    "port": 4311,
    "basePath": "/v1",
    "modelId": "agent"
  }
}
```

```bash
mono-agent validate
mono-agent start --foreground
```

### Programmatic use

Install the package directly only when composing a custom host:

```bash
pnpm add @mono-agent/openai-api-adapter
```

<!-- doc-test:typescript -->
```ts
import {
  startOpenAIApiAdapter,
  type OpenAIApiAdapterOptions,
} from "@mono-agent/openai-api-adapter";

const responder: OpenAIApiAdapterOptions["responder"] = {
  async respond(request) {
    return { text: `API request received: ${request.text}` };
  },
};

const adapter = await startOpenAIApiAdapter({
  host: "127.0.0.1",
  port: 4311,
  modelId: "agent",
  responder,
});

console.log(adapter.baseUrl);
process.once("SIGINT", () => void adapter.stop());
```

Point OpenWebUI at the printed base URL, for example `http://127.0.0.1:4311/v1`. If OpenWebUI runs in Docker while the host agent runs locally, use `http://host.docker.internal:4311/v1`. Configure the same API key in OpenWebUI when `openaiApi.apiKey` or `MONO_AGENT_OPENAI_API_KEY` is set. A non-loopback bind fails closed unless both `allowNonLoopback` and that key are present. Wildcard binds expose concrete usable loopback/private-LAN/Tailscale entries through `baseUrls`; `baseUrl` is the concrete loopback entry rather than `0.0.0.0`.

The server exposes all three of these routes under the configured `basePath`:

- `GET <basePath>/models`
- `POST <basePath>/chat/completions`
- `POST <basePath>` as a direct Chat Completions compatibility route

`stream: true` returns `text/event-stream` Chat Completions chunks followed by
`data: [DONE]`. An absent or false `stream` returns one JSON
`chat.completion` object. Telegram/Slack final-only defaults do not affect this
HTTP choice.

### Conversation sessions (Open WebUI)

The adapter derives a stable `conversationId` so the agent harness can reuse provider sessions and history across requests. Candidates, in priority order: `metadata.conversation_id` / `metadata.conversationId` / `metadata.chat_id` / `metadata.chatId`, top-level `conversation_id` / `conversationId`, the `X-OpenWebUI-Chat-Id` header, the generic `X-Conversation-Id` header, then request-body `user`. Without any of these, every request becomes a fresh conversation (`openai-api:<requestId>`).

Open WebUI strips `metadata` and other non-OpenAI fields from request bodies, so header forwarding is the path that works: set `ENABLE_FORWARD_USER_INFO_HEADERS=true` on the Open WebUI instance and it sends `X-OpenWebUI-Chat-Id` per chat. Other proxies can send `X-Conversation-Id`.

When a conversation id comes from body metadata, top-level fields, or headers, the adapter sends only the user message(s) after the last assistant message as the turn text — the harness already carries the rest of the transcript via its history store and provider sessions, so resending the full transcript would double the context. The first turn (no assistant message in the transcript yet) is still sent whole, role-prefixed, so a client system prompt is delivered once at conversation start. Requests whose only identity is `user` keep full-transcript flattening: `user` identifies a person, not a chat, and collapsing all of their chats into one latest-message conversation would lose context.

Behavior change note: clients that previously sent `metadata.conversation_id` together with full transcripts now get latest-message extraction. This is intentional — the harness owns per-conversation history. A client that mints a fresh "conversation id" per request while relying on transcript replay should stop sending an id; the fallback path preserves full-transcript semantics.

Open WebUI caveat: title and tag generation requests go to the same backend and can carry the same chat id header, landing as extra turns in the conversation's session. Point Open WebUI's Task Model (Admin Settings → Interface) at a separate lightweight model, or disable automatic title/tag generation.

Sampling parameter caveat: `temperature`, `top_p`, `max_tokens`, `max_completion_tokens`, `stop`, `seed`, `logit_bias`, `presence_penalty`, and `frequency_penalty` are preserved in `metadata.openaiApi.parameters` for compatibility, but the adapter does not currently apply them to the configured runtime. Absent parameters and explicit OpenAI defaults are quiet. A supplied non-default value emits a `runtime_warning` containing only the ignored parameter names, then the request continues with the runtime's configured values. Streaming responses render it as a reasoning delta; non-stream JSON responses include the structured event in the additive `mono_agent.events` extension. Open WebUI sampling sliders are therefore currently inert.

Rich reply parts use a separate additive extension and never change assistant
`content`. Non-stream responses expose bounded sanitized failures at
`mono_agent.reply_part_outcomes`; streaming responses emit the same field on one
metadata-only `chat.completion.chunk` before the normal `stop` chunk and
`[DONE]`. Attachments and MCP Apps terminate as `unsupported_destination`.
Unknown extensions remain ignorable by ordinary OpenAI clients, and the records
cannot contain source ids, filenames, paths, URLs, integrity values, producer
messages, or payload bytes.

Streaming responders may send structured stream events through `AgentMessageStream.event()`. Genuine assistant thoughts are emitted as `delta.reasoning_content` so OpenWebUI can render them separately from the final answer. Tool starts are not synthesized into reasoning text such as `Running Bash...`. Internally executed tools are rendered as OpenWebUI `<details type="tool_calls">` content blocks after completion. The adapter intentionally does not emit `delta.tool_calls` or `finish_reason: "tool_calls"` for host-owned tools because those fields ask the client to execute tools.

Tool-call argument and result previews each have a 128 KiB UTF-8 upper bound by
default. The adapter lowers the applied per-field bound when HTML/JSON escaping
would otherwise make the fully serialized OpenWebUI tool-details SSE frame
exceed 256 KiB. Truncated values become a valid JSON projection with
`__monoAgentTruncation` applied/original/retained/omitted byte counts plus a
code-point-safe `preview`.
Programmatic hosts can lower the preview boundary with `maxToolPayloadBytes`
(including `0` for metadata-only projections), but cannot raise it above the
default safety cap. Truncation does not replace fields on the source stream
event; payload serialization otherwise follows normal JavaScript JSON/string
conversion semantics, including any user-defined getters or `toJSON` hooks.
Whether a full event is retained in an artifact is a host-level persistence
decision.

### Open WebUI image-upload support

OpenWebUI photo uploads arrive at OpenAI-compatible backends as standard Chat Completions content parts:

```json
{
  "role": "user",
  "content": [
    { "type": "text", "text": "What is in this picture?" },
    {
      "type": "image_url",
      "image_url": {
        "url": "data:image/png;base64,...",
        "detail": "high"
      }
    }
  ]
}
```

The adapter accepts `image_url` parts and exposes the **full** structural list on
`OpenAIApiChatRequest.imageAttachments` (every accepted part: base64 `data:`,
remote `http(s)`, and `file-` URLs). Base64 `data:` images are **also** bridged
into the shared `AgentRequestBase.attachments` contract (decoded mime + base64
data), so they reach the generic app/harness path automatically. Remote/file URL
images are **not** downloaded here, so they appear only on `imageAttachments`
(and as a `metadata.openaiApi.attachments` summary) — a vision host that wants
them must read `imageAttachments` and fetch/handle the URLs itself. The text
content still feeds `request.text`, so text-only responders keep working.

The adapter does not fetch remote image URLs, validate image bytes, or claim
model-level vision support. `metadata.openaiApi.attachments` contains only a
small summary, excluding full image URLs and data payloads.

### Compatibility matrix

| Chat Completions surface | Support | Exact behavior |
| --- | --- | --- |
| `GET <basePath>/models` | Supported | Returns the one configured `modelId`. |
| `POST <basePath>/chat/completions` | Supported | Normal Chat Completions route. |
| `POST <basePath>` | Supported | Direct compatibility alias for the same handler. |
| `stream: true` | Supported | SSE `chat.completion.chunk` frames, then `[DONE]`. |
| `stream: false` or omitted | Supported | One JSON `chat.completion` response. |
| Message string content | Supported | Flattened into responder text according to conversation continuity. |
| `text` and `image_url` content parts | Supported | Text is joined; every image is structural metadata, and base64 `data:` images also enter shared attachments. |
| Remote/file image URLs | Structural only | Preserved on `imageAttachments`; never downloaded by this adapter. |
| Common sampling fields | Accepted, not applied | Values enter request metadata; non-default values produce a names-only runtime warning. |
| `tools`, `tool_choice`, `functions`, `function_call`, `response_format`, `audio`, `modalities` | Rejected | Presence of any field returns HTTP `400` `invalid_request_error`. |
| Message-level `tool_calls` or `function_call` | Rejected | Returns HTTP `400`; host-owned tools are not delegated to the client. |
| Other content-part types, including `input_audio` | Rejected | Only `text` and `image_url` are accepted. |
| Host-owned tool progress | Open WebUI extension | Completed tools render as bounded details blocks; no `delta.tool_calls` or `finish_reason: "tool_calls"` is emitted. |
| Rich reply parts | Mono-agent extension | Assistant content is unchanged; sanitized terminal failures use `mono_agent.reply_part_outcomes` in JSON and metadata-only SSE chunks. |
| Responses, Embeddings, Files, Images, and Audio APIs | Not implemented | Outside this package's Chat Completions boundary. |

## Architecture

### Data flow

The request lifecycle is:

1. `config.ts` loads the opt-in server settings, validates the base path and
   safe-bind/auth boundary, and redacts the optional bearer token.
2. `server.ts` registers model discovery plus the canonical and direct POST
   routes, authenticates and validates each body, and resolves conversation
   identity and supported content.
3. Supported messages become one `OpenAIApiChatRequest`. Base64 `data:` images
   also enter the shared attachment contract; remote/file URLs remain structural.
4. The host responder writes to either an SSE-backed stream (`stream: true`) or
   a buffered JSON stream (`stream: false`). Runtime warnings and host-owned tool
   details use additive compatibility extensions.
5. Client disconnect or `stop()` aborts the request; shutdown closes the server
   and waits for active transport cleanup.

### Package structure

| Source module | Responsibility |
| --- | --- |
| [`config.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/openai-api-adapter/src/config.ts) | Config/env loading, safe-bind validation, base-path normalization, and redaction. |
| [`server.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/openai-api-adapter/src/server.ts) | HTTP routes, request validation, conversation/content normalization, SSE/JSON rendering, and shutdown. |
| [`constants.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/openai-api-adapter/src/constants.ts) | Public defaults and hard tool-payload bounds. |
| [`errors.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/openai-api-adapter/src/errors.ts) | Stable adapter error codes and details. |
| [`index.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/openai-api-adapter/src/index.ts) | Supported public package surface. |

## Public API

### Start here

| API | Use it for |
| --- | --- |
| `loadOpenAIApiAdapterConfig` | Load and validate the `openaiApi` config/env surface. |
| `startOpenAIApiAdapter` | Start model discovery and Chat Completions routes. |
| `OpenAIApiAdapterOptions` / `OpenAIApiAdapterStartResult` | Compose the server and consume concrete advertised URLs. |
| `OpenAIApiChatRequest` | Implement the responder-facing text/image request contract. |
| `OpenAIApiAttachment` | Handle accepted `image_url` parts structurally. |
| `OpenAIApiAdapterError` | Classify invalid config/request and startup failures. |
| `DEFAULT_MAX_TOOL_PAYLOAD_BYTES` / `MAX_TOOL_SSE_FRAME_BYTES` | Bound Open WebUI tool-detail serialization. |

<!-- public-api-inventory:start -->
<!-- Generated by scripts/generate-public-api-docs.mjs. Do not edit by hand. -->

Every symbol exported by each public code entrypoint is listed below.

**`@mono-agent/openai-api-adapter`**

```text
DEFAULT_MAX_TOOL_PAYLOAD_BYTES
LoadOpenAIApiAdapterConfigInput
MAX_TOOL_SSE_FRAME_BYTES
OPENAI_API_CONFIG_FIELDS
OpenAIApiAdapterConfig
OpenAIApiAdapterError
OpenAIApiAdapterErrorCode
OpenAIApiAdapterErrorDetails
OpenAIApiAdapterLogger
OpenAIApiAdapterOptions
OpenAIApiAdapterStartResult
OpenAIApiAttachment
OpenAIApiAttachmentMetadata
OpenAIApiAttachmentUrlKind
OpenAIApiChatRequest
OpenAIApiImageAttachment
OpenAIApiImageAttachmentMetadata
OpenAIApiImageDetail
OpenAIApiRequestMetadata
RedactedOpenAIApiAdapterConfig
loadOpenAIApiAdapterConfig
redactOpenAIApiAdapterConfig
startOpenAIApiAdapter
```

<!-- public-api-inventory:end -->

## Dependency Boundary

This adapter depends on Express plus shared `@mono-agent/agent-contracts` primitives. It must not depend on the agent harness, runtime adapter, operator surfaces, memory, observability, other communication adapters, or host composition code. Hosts compose it with a structural responder.

## What This Package Does Not Own

It does not build prompts, run models, persist conversations, implement the Responses API, embeddings, image generation/editing, audio, OpenAI Files API storage, OpenAI tool/function calling, TLS, or public deployment policy. The adapter binds to loopback by default; public deployment safety is host or reverse-proxy responsibility.

## Related Documentation

- [OpenAI-compatible API channel guide](https://docs.mono-agent.dev/channels/openai-api/)
- [Open WebUI integration playbook](https://docs.mono-agent.dev/playbooks/openai-endpoint-open-webui/)
- [Sessions and concurrency](https://docs.mono-agent.dev/runtime/sessions-concurrency/)
- [Tool policy](https://docs.mono-agent.dev/tools/policy/)

## Verification

Run the package-local build, typecheck, and behavior tests:

```bash
pnpm --filter @mono-agent/openai-api-adapter run build
pnpm --filter @mono-agent/openai-api-adapter run typecheck
pnpm --filter @mono-agent/openai-api-adapter run test
```
