---
title: "OpenAI protocol"
description: "Models on pirouter that accept the OpenAI protocol work with the official OpenAI SDK after three changes: the base URL, the API key and the model ID. Chat Completions and Responses parameters are forwarded as they are; the differences are in the model name, streamed usage and the error body."
canonical: "https://pirouter.ai/docs/api/openai"
last-updated: "2026-10-17"
lang: "en"
source: "content/docs/en/api/openai.mdx"
---

# OpenAI protocol

Models on pirouter that accept the OpenAI protocol work with the official OpenAI SDK after three changes: the base URL, the API key and the model ID. Chat Completions and Responses parameters are forwarded as they are; the differences are in the model name, streamed usage and the error body.

The official OpenAI SDK works with pirouter after three changes: set the base URL to `https://api.pirouter.ai/v1`, use your pirouter API key, and write model IDs as `vendor/model`. Every other request parameter is forwarded as is and written as in OpenAI's own documentation.

The OpenAI protocol has four endpoints: `POST /v1/chat/completions`, `POST /v1/responses`, `POST /v1/images/generations` and `POST /v1/images/edits`. The first two are covered here; for the image endpoints see [Generate and edit images](https://pirouter.ai/docs/guides/images.md).

## Send a request with the official SDK

The SDK sends the key in the `Authorization: Bearer` header.

Chat Completions:

cURL:

```bash
curl https://api.pirouter.ai/v1/chat/completions \
  -H "Authorization: Bearer $PIROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-6.1-sol",
    "messages": [{ "role": "user", "content": "Hello!" }]
  }'
```

Python:

```python
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://api.pirouter.ai/v1",
    api_key=os.environ["PIROUTER_API_KEY"],
)

completion = client.chat.completions.create(
    model="openai/gpt-6.1-sol",
    messages=[{"role": "user", "content": "Hello!"}],
)
print(completion.choices[0].message.content)
```

TypeScript:

```ts
import OpenAI from 'openai'

const client = new OpenAI({
  baseURL: 'https://api.pirouter.ai/v1',
  apiKey: process.env.PIROUTER_API_KEY,
})

const completion = await client.chat.completions.create({
  model: 'openai/gpt-6.1-sol',
  messages: [{ role: 'user', content: 'Hello!' }],
})
console.log(completion.choices[0].message.content)
```

Responses:

cURL:

```bash
curl https://api.pirouter.ai/v1/responses \
  -H "Authorization: Bearer $PIROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-6.1-sol",
    "input": "Hello!"
  }'
```

Python:

```python
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://api.pirouter.ai/v1",
    api_key=os.environ["PIROUTER_API_KEY"],
)

response = client.responses.create(
    model="openai/gpt-6.1-sol",
    input="Hello!",
)
print(response.output_text)
```

TypeScript:

```ts
import OpenAI from 'openai'

const client = new OpenAI({
  baseURL: 'https://api.pirouter.ai/v1',
  apiKey: process.env.PIROUTER_API_KEY,
})

const response = await client.responses.create({
  model: 'openai/gpt-6.1-sol',
  input: 'Hello!',
})
console.log(response.output_text)
```

On success each returns a reply from the model, and both requests show up in the console's **Logs**.

Request fields pirouter does not know are forwarded to the provider as they are. Only four top-level fields are pirouter's own and are not forwarded: `provider`, `models`, `route` and `fallbacks`. See [Provider routing](https://pirouter.ai/docs/guides/routing.md) and [Model fallbacks](https://pirouter.ai/docs/guides/fallbacks.md).

## Stream the response

Add `"stream": true` to the request body and the response becomes an SSE event stream:

```bash
curl -N https://api.pirouter.ai/v1/chat/completions \
  -H "Authorization: Bearer $PIROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-6.1-sol",
    "stream": true,
    "messages": [{ "role": "user", "content": "Hello!" }]
  }'
```

The stream is a series of `data: ` events that ends with `data: [DONE]`.

On a streamed Chat Completions request pirouter always turns on `stream_options.include_usage`, even if you sent `false`. So there is always one event with an empty `choices` and a `usage` object before `[DONE]`. Streamed Responses requests are left as they are.

## Use tools, structured output and image input

pirouter forwards these parameters as they are and does not check whether the model supports them. A parameter the model does not support is still forwarded, and the provider decides whether to accept it, ignore it or return an error.

Before you call, check the model's fields in `GET /v1/models`:

| What you want                     | What you send                                                                                 | Supported when                                         |
| --------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| Tool calls                        | `tools`, `tool_choice`                                                                        | `supported_parameters` includes `tools`, `tool_choice` |
| Several tool calls in one turn    | `parallel_tool_calls`                                                                         | `supported_parameters` includes `parallel_tool_calls`  |
| JSON output                       | `response_format: {"type": "json_object"}`                                                    | `supported_parameters` includes `response_format`      |
| Output that follows a JSON schema | `response_format: {"type": "json_schema", …}`                                                 | `supported_parameters` includes `structured_outputs`   |
| Image input                       | An `image_url` part in the message content, which may be a `data:` URL                        | `architecture.input_modalities` includes `image`       |
| PDF input                         | A `file` part in the message content, with `file_data` as a base64 `data:application/pdf` URL | `architecture.input_modalities` includes `file`        |

## Control reasoning

Set the reasoning level with `reasoning_effort` (Chat Completions) or `reasoning.effort` (Responses); it is forwarded as is. There are seven levels: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`. The levels a model accepts are in `reasoning.supported_efforts` in `GET /v1/models`, and the level it uses when you send none is in `reasoning.default_effort`.

To tell whether a model reasons, look for `reasoning` in `supported_parameters`. That list does not include `reasoning_effort`.

Reasoning tokens are reported in `usage.completion_tokens_details.reasoning_tokens` and billed at the model's `internal_reasoning` price.

On Chat Completions, `thinking: {"type": "disabled"}` is replaced with `reasoning_effort: "none"` before forwarding. If you also sent `reasoning_effort` or `reasoning`, your value is kept.

## How pirouter differs from OpenAI

| Area                        | What pirouter does                                                                                                                                                                                        |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `model` in the response     | Rewritten to the pirouter model ID (`vendor/model`), in every event of a stream too. The name the provider reported is in the `X-Pi-Upstream-Model` response header                                       |
| Streamed usage              | A streamed Chat Completions response always has a usage event; see [Stream the response](https://pirouter.ai/docs/api/openai.md#stream-the-response)                                                      |
| Error body                  | pirouter's own errors are `{"error": {"message", "type", "code"}}`, with `code` a string error code; see [Errors](https://pirouter.ai/docs/api/errors.md)                                                 |
| Routing fields              | `provider`, `models`, `route` and `fallbacks` are not forwarded                                                                                                                                           |
| No reasoning parameter sent | Some providers have a default reasoning level: when the request has no `reasoning_effort`, `reasoning` or `thinking`, pirouter writes that level in                                                       |
| Sampling parameters         | Some providers accept only some sampling parameters (such as `temperature`, `top_p`, `seed`, `stop`); the others are removed before forwarding, without an error                                          |
| Reasoning budget            | Some providers limit reasoning only by a token budget: pirouter converts the level into a budget and splits your output limit between reasoning and reply, so the two never add up to more than the limit |
| Forced tool calls           | With `tool_choice` set to `"required"` or to a named function, some models do not always call the tool: the request still returns `200`, and the reply may be plain text                                  |
| Failover                    | When a provider fails before sending its first byte, pirouter moves to the next provider, up to 5 attempts; see [Failover](https://pirouter.ai/docs/guides/routing.md#failover)                           |

## When a request fails

Branch on `error.code`; [Errors](https://pirouter.ai/docs/api/errors.md) gives the meaning of every code and whether to retry it. The one you are most likely to meet on this protocol is `422` `validation_failed`: the model is not served on the endpoint you called, such as an image model on Chat Completions. The `message` lists the endpoints it is served on; see [Protocol mismatch](https://pirouter.ai/docs/models.md#protocol-mismatch).

## Not available today

pirouter does not serve these OpenAI endpoints today:

- `POST /v1/embeddings`
- `GET /v1/responses/{id}`

A call to either path returns `400` `invalid_request` with the `message` `model is required`. It means pirouter does not serve the endpoint, not that your request is missing a field; see [`invalid_request`](https://pirouter.ai/docs/api/errors.md#invalid_request).

## Next steps

- [Create chat completion](https://pirouter.ai/docs/api/chat-completions.md): Every parameter and response field of the Chat Completions endpoint.
- [Anthropic protocol](https://pirouter.ai/docs/api/anthropic.md): Call Claude and other models with the official Anthropic SDK.
