# Chat completions (https://hypit.ai/api-reference/chat/)

> The drop-in OpenAI endpoint — synchronous, streaming, vision, tools, vendor passthrough.

`POST /v1/chat/completions` is the most compatible surface on the API. Requests are forwarded to the
upstream essentially byte for byte and the response comes back the same way, so anything an
OpenAI-compatible client can send, it can send here.

```bash
curl https://hypit.ai/v1/chat/completions \
  -H "Authorization: Bearer $HYPIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gemini-3.1-pro",
    "messages": [
      {"role": "system", "content": "Answer in one sentence."},
      {"role": "user", "content": "What is Hypit?"}
    ]
  }'
```

## With the OpenAI SDKs [#with-the-openai-sdks]

```python
import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["HYPIT_API_KEY"],
    base_url=os.environ.get("HYPIT_BASE_URL", "https://hypit.ai/v1"),
)

completion = client.chat.completions.create(
    model="gemini-3.1-pro",
    messages=[{"role": "user", "content": "What is Hypit?"}],
    max_tokens=200,
)
print(completion.choices[0].message.content)
print(completion.usage)
```

```js
import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.HYPIT_API_KEY,
  baseURL: process.env.HYPIT_BASE_URL ?? "https://hypit.ai/v1",
});

const completion = await client.chat.completions.create({
  model: "gemini-3.1-pro",
  messages: [{ role: "user", content: "What is Hypit?" }],
  max_tokens: 200,
});
console.log(completion.choices[0].message.content);
```

## What we read, and what we forward [#what-we-read-and-what-we-forward]

Only two fields are required, and only a handful are inspected at all:

| Field            | Why we read it                                               |
| ---------------- | ------------------------------------------------------------ |
| `model`          | **required** — chooses the route                             |
| `messages`       | **required**, non-empty — walked to find image parts         |
| `stream`         | selects the SSE path                                         |
| `stream_options` | see below                                                    |
| `modalities`     | `["text","image"]` asks image-capable chat models for pixels |

Everything else — `max_tokens`, `max_completion_tokens`, `temperature`, `top_p`, `stop`, `tools`,
`tool_choice`, `response_format`, `reasoning_effort`, and any vendor-private key we have never heard
of — rides through to the upstream untouched. The body is capped at **32 MiB**.

The response body is the upstream's, verbatim, with exactly two deviations:

1. Vendor billing keys are stripped (`credits`, `cost`, `cost_usd`, `balance`, `billing` and their
   siblings, at the top level and inside `usage`) so that another vendor's ledger never leaks into
   yours. Your real cost is in credits, not in that field.
2. If we mapped your model name onto a different upstream name, the top-level `"model"` string is
   rewritten back to the name you asked for. Where no mapping applies, the vendor's own refinement
   (`gpt-4o-2024-08-06` and the like) is preserved.

`usage` is otherwise untouched — whatever the upstream reported is what you see. We normalise it
internally for billing, but that never changes the bytes on the wire.

## Streaming [#streaming]

Set `"stream": true`. The response is `text/event-stream`, chunks are the upstream's frames verbatim,
and the stream ends with `data: [DONE]` — we add the sentinel if the upstream did not.

```python
stream = client.chat.completions.create(
    model="gemini-3.1-pro",
    messages=[{"role": "user", "content": "Write a haiku about a paper boat."}],
    stream=True,
)
for chunk in stream:
    delta = chunk.choices[0].delta.content
    if delta:
        print(delta, end="", flush=True)
```

```js
const stream = await client.chat.completions.create({
  model: "gemini-3.1-pro",
  messages: [{ role: "user", content: "Write a haiku about a paper boat." }],
  stream: true,
});
for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
```

```bash
curl -N https://hypit.ai/v1/chat/completions \
  -H "Authorization: Bearer $HYPIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gemini-3.1-pro","stream":true,
       "messages":[{"role":"user","content":"Write a haiku about a paper boat."}]}'
```

### Usage on a stream [#usage-on-a-stream]

If you do not send `stream_options` at all, we add `{"include_usage": true}` for you, so the last
frame carries a `usage` block. If you send `stream_options` yourself — including
`{"include_usage": false}` — we leave it exactly as written.

<Callout type="warn" title="A stream never fails over, and never changes status">
  Once the first byte is on the wire the HTTP status is `200` for good. A failure after that arrives as
  one SSE frame carrying the standard error envelope, followed by `data: [DONE]`:

  ```text
  data: {"error":{"message":"…","type":"upstream_error","code":"stream_broken","param":null}}

  data: [DONE]
  ```

  Parse `error` on every frame, not just on the HTTP status. Note also that disconnecting mid-stream
  does not stop the request being metered — the tokens were generated.
</Callout>

## Vision [#vision]

Image parts work the way they do on OpenAI:

```json
{
  "model": "gemini-3.1-pro",
  "messages": [
    {
      "role": "user",
      "content": [
        { "type": "text", "text": "What is in this picture?" },
        { "type": "image_url", "image_url": { "url": "https://example.com/boat.png" } }
      ]
    }
  ]
}
```

An `http(s)` image URL is **downloaded by the gateway** and inlined as a `data:` URL before a
provider is chosen, so a model that only accepts inline images still works. Up to four images are
fetched concurrently within a 20-second budget; exceeding it is `400 image_fetch_timeout`. `data:`
URLs are passed through as-is and are always the faster path.

Only `http`, `https` and `data:` schemes are accepted, and the fetch is guarded against private and
link-local addresses on every redirect.

## Errors [#errors]

| Status | `code`                                               | Meaning                                            |
| ------ | ---------------------------------------------------- | -------------------------------------------------- |
| `400`  | `empty_body` / `invalid_request_body`                | body missing or not a JSON object                  |
| `400`  | `missing_model` / `missing_messages`                 | both are required                                  |
| `400`  | `invalid_message_content`                            | a `content` value could not be decoded             |
| `400`  | `invalid_media_url` / `unsupported_media_url_scheme` | an `image_url.url` failed policy                   |
| `400`  | `image_fetch_timeout` / `image_fetch_failed`         | a referenced image could not be retrieved          |
| `413`  | `request_too_large`                                  | over 32 MiB                                        |
| `404`  | `model_not_found` / `no_capable_provider`            | the model is not routable for chat under your key  |
| `502`  | `empty_response` / `empty_stream`                    | the upstream returned nothing                      |
| `502`  | `stream_broken`                                      | the stream ended early — delivered as an SSE frame |
| `503`  | `no_available_provider`                              | every candidate provider is cooling down           |
| `504`  | `request_timeout`                                    |                                                    |

Upstream failures are classified rather than passed through raw: `upstream_error` (502),
`upstream_rate_limited` (429), `upstream_quota_exhausted` (402), `upstream_unauthorized` (401) and
`upstream_rejected_request` (the vendor's own 4xx, message forwarded after redaction). Provider
names, base URLs, upstream model names and credentials are stripped from anything we return.

See [Errors and limits](/api-reference/errors) for the shared codes.