# OpenAI-compatible API

> Tracon documentation. Tracon is a .NET package family that adds a control
> plane on Microsoft Agent Framework, published as NuGet packages that run
> inside your own ASP.NET Core application.
>
> Page: https://tracon.dev/guides/openai-api/
> Summary: Point OpenAI clients at Tracon, choose an agent with model, stream responses, and understand the exact compatibility boundary.

Tracon exposes the two OpenAI request styles most application clients already
understand:

- `POST {prefix}/v1/responses`
- `POST {prefix}/v1/chat/completions`
- `/v1/conversations/*` for Responses conversation state

```mermaid
flowchart TD
    accTitle: The two compatible surfaces and who owns the conversation
    accDescr: Responses keeps conversation state on the server, reached either by previous_response_id or by an explicitly reserved conversation. Chat Completions carries its history in the request. Both resolve the model field to a Tracon agent, which then chooses its own provider model.
    RESP["POST /v1/responses"] --> SRV["Tracon owns the session"]
    CONV["POST /v1/conversations"] --> SRV
    PREV["previous_response_id"] --> SRV
    CHAT["POST /v1/chat/completions"] --> CLI["The client owns the message list"]
    SRV --> PICK["model selects a Tracon agent"]
    CLI --> PICK
    PICK --> AGENT["Agent: instructions · tools · skills<br/>memory · guards · budgets"]
    AGENT --> MODEL["The provider model the agent binds to"]
```

This is an **agent surface**, not a transparent model proxy. The request's `model`
selects a Tracon agent. That agent then selects its provider model, instructions,
tools, skills, memory, guards, and budgets on the server.

## Responses API: recommended for stateful clients

The official OpenAI SDK uses `client.responses.create(model=..., input=...)`; point
the same client shape at Tracon and use a Tracon credential:

```python
import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["TRACON_API_KEY"],
    base_url="https://agents.example.com/tracon/v1",
)

response = client.responses.create(
    model="support",
    input="Where is order 4182?",
)

print(response.output_text)
```

If your generated SDK version expects the base URL without `/v1`, follow that SDK's
base-URL rule. The request that reaches Tracon must end at
`{prefix}/v1/responses`.

The same call without an SDK is unambiguous:

```bash
curl -sS https://agents.example.com/tracon/v1/responses \
  -H "Authorization: Bearer $TRACON_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"model":"support","input":"Where is order 4182?"}'
```

The response has the familiar `response` object, `resp_...` identifier, output items,
status, and usage when the underlying provider reports it. Errors use the OpenAI
`{"error": { ... }}` envelope so an OpenAI client can parse them.

### Stream Responses events

Set `stream=true`. The server returns SSE with OpenAI event names, including
`response.created`, `response.output_text.delta`, and `response.completed`:

```python
stream = client.responses.create(
    model="support",
    input="Summarize the escalation in three bullets.",
    stream=True,
)

for event in stream:
    if event.type == "response.output_text.delta":
        print(event.delta, end="", flush=True)
```

This follows the official Responses streaming model. Tracon still creates its own
run and ordered event history behind the compatible stream.

## Conversation state

Use either mechanism supported by Responses:

```json
{
  "model": "support",
  "input": "Second turn",
  "previous_response_id": "resp_..."
}
```

Tracon stores the session under the first response identifier, so
`previous_response_id` finds its history. Or reserve and reuse an explicit
conversation:

```bash
CONVERSATION_ID=$(curl -sS -X POST \
  https://agents.example.com/tracon/v1/conversations \
  -H "Authorization: Bearer $TRACON_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{}' | jq -r .id)

curl -sS https://agents.example.com/tracon/v1/responses \
  -H "Authorization: Bearer $TRACON_API_KEY" \
  -H 'Content-Type: application/json' \
  -d "{\"model\":\"support\",\"conversation\":\"$CONVERSATION_ID\",\"input\":\"Hello\"}"
```

Creating a conversation reserves an identifier; the session is born on the first
response call. Tenant ownership is checked on every reference. An identifier owned
by another tenant returns `404`, not evidence that the resource exists.

### The conversation routes go through your own gates

`GET`, `DELETE` and `GET …/items` reach the same sessions `/api/sessions/{id}`
reaches, so they pass the same two checks before answering:

- **Session ownership**, when `Tracon:SessionOwnership` is on — including
  `RefuseUnownedSessions`. See
  [Sessions: session ownership](/concepts/sessions/#session-ownership).
- **Your registered `IRunAuthorizationHandler`**, with `SessionAccess.Read` on
  the two reads and `SessionAccess.Delete` on the delete.

:::caution[Behaviour change]
Before this, the conversation routes never asked your handler. If you have one
registered and it refuses some sessions, it now refuses them here too — the
direction is fail-closed, but it is a change. `POST /v1/conversations` is not
gated: it reserves an identifier and writes nothing.
:::

If your deployment does not use these routes at all, leave them unmapped:

```csharp
app.MapTracon("/tracon", options => options.MapOpenAIConversations = false);
```

The four paths then answer `404` and disappear from the OpenAPI document.
`/v1/responses` and `/v1/chat/completions` are unaffected.

## Chat Completions: stateless history

Chat Completions remains useful for clients that carry their own message list:

```python
completion = client.chat.completions.create(
    model="support",
    messages=[
        {"role": "user", "content": "Where is order 4182?"},
    ],
)

print(completion.choices[0].message.content)
```

`stream=True` returns `chat.completion.chunk` frames and ends with `[DONE]`.
Tracon does not open a server session for this endpoint. Send the full history on
every call; otherwise the next turn has no previous context.

A message's content can be a string or a list of parts. Tracon hands each part to the
model or refuses the request with `400` and the part's location; it never drops a part:

| Part | Reaches the model as |
|---|---|
| `text`, `refusal` | Text |
| `image_url` with a `data:` URL | Image bytes; `detail` is kept |
| `image_url` with an `http(s)` URL | A link the provider fetches; `detail` is kept |
| `input_audio` (`wav`, `mp3`) | Audio bytes |
| `file` with `file_data` | File bytes, with `filename` as the name |
| `file` with `file_id`, or any other type | Refused: Tracon keeps no OpenAI file store |

Embedded bytes go through the same check as an upload: the type is read from the bytes,
not from the declared media type, must be listed in `Attachments:AllowedMediaTypes` and
must fit in `Attachments:MaxBytes` ([configuration](/reference/configuration/)). The data is stored as an attachment of the tenant with no session, so
retention removes it later. Whether a model can read a given image, audio clip or file
depends on its provider.

The roles are `system`, `developer` (read as `system`), `user` and `assistant`. A `tool`
or `function` message, and an assistant message with `tool_calls` or `function_call`,
answer `400`: Tracon runs the agent's tools on the server, so there is no client-side
tool call to report.

The agent definition owns the model settings, so a request setting is never silently
ignored. These fields answer `400` (`invalid_request_error`), and the message names every
one the request sets: `temperature`, `top_p`, `max_tokens`, `max_completion_tokens`,
`frequency_penalty`, `presence_penalty`, `seed`, `stop`, `logit_bias`, `reasoning_effort`,
`response_format`, `tools`, `tool_choice`, `functions`, `function_call`, `n`, `logprobs`,
`top_logprobs`, `modalities`, `audio`, and `web_search_options`. A value that asks for the
default passes: `n: 1`, `response_format: {"type": "text"}`, `logprobs: false`, an empty
`tools` list, `modalities: ["text"]`, or `null`. Fields that carry no setting the agent uses
(`user`, `store`, `metadata`, `stream_options`) pass too. `/v1/responses` refuses its own
setting fields (`temperature`, `top_p`, `max_output_tokens`, `instructions`, `tools`,
`tool_choice`) the same way. To change a setting, change the agent.

## Calling these endpoints from a typed client

Both endpoints answer with **either** JSON **or** SSE, and the `stream` flag in the
request body decides which — at request time. An OpenAPI document cannot describe a
response shape chosen that way, so a generated client cannot pick one signature that
covers both. Each endpoint therefore has **two methods**, and you choose by calling
one of them:

| You want | .NET method | Returns |
|---|---|---|
| One complete answer | `TraconOpenAIResponsesAsync` | `Task<JsonElement>` |
| Frames as they arrive | `TraconOpenAIResponsesStreamAsync` | `IAsyncEnumerable<string>` |

```csharp
var body = JsonSerializer.SerializeToElement(
    new { model = "support", input = "Where is order 4182?", stream = true });

await foreach (var frame in client.TraconOpenAIResponsesStreamAsync(body))
{
    // One raw SSE frame per element, e.g.
    // "event: response.output_text.delta\ndata: {...}"
    Console.WriteLine(frame);
}
```

The frame is handed over **as the server flushes it**, so a `break` or a cancelled
token stops the read and releases the connection. Comment-only keep-alive blocks are
skipped. The element is the raw frame rather than a parsed event type: the payload
follows OpenAI's schema, not Tracon's, so parsing it is yours to control.

`TraconRunAgentStreamAsync` and the other `*StreamAsync` methods give the
management API's streaming endpoints the same shape.

:::caution[The method and the body must agree]
The client sends your body **exactly as written** — it never rewrites `stream` to
match the method you called. If the two disagree, the call fails with a message that
names the fix rather than failing obscurely later:

> The server answered 200 with content type `application/json`, not
> `text/event-stream`. Send `"stream": true`, or call
> `TraconOpenAIResponsesAsync` for the JSON shape.
:::

In TypeScript no special method is needed — ask for the raw stream and decode it with
the reader the package ships:

```ts
import { createTraconClient, readSse } from '@tracon/client';

const client = createTraconClient({
  baseUrl: 'https://agents.example.com/tracon',
  token: process.env.TRACON_API_KEY,
});

const { response } = await client.POST('/v1/responses', {
  body: { model: 'support', input: 'Where is order 4182?', stream: true },
  parseAs: 'stream',
});

for await (const frame of readSse(response)) {
  console.log(frame.event, frame.data);
}
```

## Compatibility matrix

| Capability | Responses | Chat Completions | Tracon behavior |
|---|---|---|---|
| Text input and output | yes | yes | Runs the selected agent and records the run |
| SSE streaming | yes | yes | Uses surface-specific OpenAI frame names |
| Server conversation | `conversation` or `previous_response_id` | no | Maps to Tracon sessions |
| Client-carried history | input items | `messages` | Resolved attachments reach the provider |
| Image, audio and file input | `input_image`, `input_file` | `image_url`, `input_audio`, `file` | Embedded data passes the attachment type check and is stored as an attachment of the tenant |
| Client tool-call history | no: `400` | no: `400` | Tools run on the server; send earlier turns as text |
| Agent tool loop | yes | yes | Tools are defined and executed server-side |
| Pending approval | yes, in `output` | no: `409` | Chat Completions carries no session, so an approval can never be answered |
| Usage | when provider reports it | when provider reports it | Missing usage remains unknown, not zero |
| Cut-off answer | no: `status` stays `completed` | `finish_reason: "length"` | See below |
| Request model settings (`temperature`, `tools`, `response_format`, …) | no: `400` | no: `400` | The agent definition sets them |
| OpenAI hosted tools | not a pass-through | not a pass-through | Configure Tracon tools/MCP instead |
| Background Responses mode | no | no | Use `Prefer: respond-async` on the management run endpoint |

A Chat Completions request whose agent asks to call a tool that needs approval ends
there. The answer to an approval is the next turn of a session, and this endpoint has
none, so nobody could ever answer it. The run closes as `Failed`, and the caller gets
`409` with the error type `approval_required` — or, on a stream that already answered
`200`, an `error` frame of that type and no `[DONE]`. Run such an agent through
`/v1/responses` with a conversation, or through `POST /api/agents/{name}/run` with a
`sessionId`.

Chat Completions sets `finish_reason` from the answer's final model call, on the JSON
response and on the last chunk of a stream (`data: [DONE]` still follows). An answer
the model cut off at its output token limit or its context window is `"length"`; an
answer a content filter cut off after it produced text is `"content_filter"`; anything
else is `"stop"`. Tools run on the server, so a tool turn's `tool_calls` reason is never
reported. The value does not depend on run recording. The Responses endpoint does not
mark a cut-off answer `incomplete` yet: its `status` reads `completed`, and the run's
`tracon.output.truncated` event is the only signal there
(see [Truncated output](/concepts/runs/#truncated-output)).

A field that carries no model setting can be accepted without becoming a Tracon
feature. Do not assume every OpenAI storage flag, service tier, or retrieval surface
is forwarded to the agent's provider. The server-side definition is authoritative.
`POST /v1/conversations` takes `metadata` only: a request with initial `items` answers
`400`, and the first turn goes to `/v1/responses` with the conversation's id.

## Authentication and authorization

Use the bearer token configured on `MapTracon`, or a tenant-bound Tracon API
key with `RunsWrite`<!-- claim:policy POST /tracon/v1/chat/completions scope=RunsWrite --> — these routes start runs, so they take the same scope the
management run endpoints take. `GET /v1/conversations/{id}` and its items route
take `RunsRead` instead. `ExternalInvoke` is the scope for the MCP and A2A
surfaces and does **not** authorize these routes: a key issued with only
`ExternalInvoke` is rejected here. The base URL, credential, and model semantics
all change; test them explicitly when moving an existing client.

For a browser or untrusted device, do not embed a long-lived control-plane key.
Terminate user authentication in your application and issue the narrowest credential
your architecture permits.

## Failure behavior

| Symptom | Meaning |
|---|---|
| `400` with listed agents | No valid `model` or `metadata.entity_id` selected an agent |
| `401` | Bearer token or API key is missing, invalid, revoked, or expired |
| `403` | Role, scope, loopback, or tenant rule refused the call |
| `404 model_not_found` | The selected Tracon agent does not resolve |
| `422` | A Tracon content guard or idempotency contract rejected the request |
| `429` | A rate or quota boundary was reached |
| `502` | The configured upstream provider failed |

Use the management run API when you need Tracon-specific controls such as
queued execution, idempotent non-streaming responses, explicit replay, cancellation,
or complete run diagnostics.

The client method and streaming examples follow the
[official OpenAI Responses documentation](https://developers.openai.com/api/docs/guides/text).
The behavior and compatibility limits above are Tracon's own contract.

## Read next

- [HTTP API conventions](/http-api/) — the management API, which is a different surface with different rules
- [Model providers](/guides/model-providers/) — what actually answers the request behind the compatible endpoint
- [Attachments and multimodal input](/guides/multimodal/) — how non-text content arrives through the same endpoints
