---
title: How the gateway works
description: One host serves the Anthropic, OpenAI and Gemini APIs natively. Endpoints, base URLs, accepted model ids, auth headers and what Half Off Dev touches.
---

The point of Half Off Dev is that you keep the code you already have. Claude, GPT and Gemini are each served in their own vendor's wire format, on a single host, at half the list price. Your SDK sends the same JSON it would send to the vendor; only the base URL and the key change.

There is no translation layer in the middle. An Anthropic-style request is handed to a channel that speaks Anthropic, an OpenAI-style request to one that speaks OpenAI, and so on. Apart from [two narrow edits](#what-changes), the body arrives as you wrote it, so vendor features like `thinking`, `cache_control`, `tools` or `generationConfig` behave exactly as each vendor's own reference describes.

## Endpoints at a glance {#endpoints}

Give your SDK the base URL from this table and let it build the path.

| Wire format | Paths | Base URL for the SDK | Guide |
| --- | --- | --- | --- |
| Anthropic Messages | `POST /v1/messages`, `POST /v1/messages/count_tokens` | `https://halfoff.dev` | [Anthropic Messages](/docs/models/anthropic) |
| OpenAI Chat Completions | `POST /v1/chat/completions` | `https://halfoff.dev/v1` | [OpenAI](/docs/models/openai) |
| OpenAI Responses (GPT only) | `POST /v1/responses` | `https://halfoff.dev/v1` | [Responses API](/docs/models/openai#responses-api) |
| Gemini | `POST /v1beta/models/{model}:generateContent`, `:streamGenerateContent`, `:countTokens` | `https://halfoff.dev` | [Gemini](/docs/models/gemini) |
| Model catalog | `GET /v1/models`, `GET /v1beta/models` | | [Pricing](/docs/models/pricing) |

Anything outside this list is unsupported; see [what you can't call](#not-available).

## Sending your key {#authentication}

Each endpoint looks for the key in four places, so whatever an SDK does by default just works:

| Location | Typical sender |
| --- | --- |
| `Authorization: Bearer ho-...` | OpenAI SDKs, Claude Code via `ANTHROPIC_AUTH_TOKEN`, plain HTTP clients |
| `x-api-key: ho-...` | Anthropic SDKs |
| `x-goog-api-key: ho-...` | Google Gen AI SDKs, Gemini CLI |
| `?key=ho-...` | Gemini REST snippets |

The two catalog endpoints are public and need no key. Creating, limiting and rotating keys is covered in [Authentication](/docs/authentication).

## Native format per model family {#native-formats}

Each family was designed around one API. Calling it there gives you every feature the vendor ships.

| Family | Native format | Also accepted today |
| --- | --- | --- |
| Claude (`claude-*`) | Anthropic Messages | OpenAI Chat Completions, Gemini |
| GPT (`gpt-*`) | OpenAI Chat Completions, OpenAI Responses | Anthropic Messages, Gemini |
| Gemini (`gemini-*`) | Gemini | OpenAI Chat Completions, Anthropic Messages |

Using a non-native format means the channel converts your request for the model, and vendor-only options may be dropped or refused on the way. Anthropic's `cache_control` and `thinking`, OpenAI's `reasoning_effort` and Gemini's `thinkingConfig` are only guaranteed in the model's home format.

Which formats a model accepts follows from the channels serving it, so the list can move. The [model marketplace](/models) shows the current formats per model. When no channel takes your format, you get `404` with a message such as `Model 'gpt-6.1-sol' is not served in gemini format in any group.`

## Model ids {#model-ids}

The canonical ids are the ones in [`GET /v1/models`](/docs/models/pricing), for example `claude-opus-5.5`, `gpt-6.1-sol` or `gemini-3-flash`. Tools that spell ids their own way still land on the right model, because the gateway normalizes what it receives:

| Sent | Treated as |
| --- | --- |
| `Claude-Opus-5-5` | `claude-opus-5.5` |
| `anthropic/claude-opus-5.5` | `claude-opus-5.5` |
| `claude-haiku-4-5-20251001` | `claude-haiku-4.5` |
| `gemini-3.1-pro-preview` | `gemini-3.1-pro` |
| `models/gemini-3-flash` | `gemini-3-flash` |

Normalization lower-cases the id, strips a leading `vendor/` or `models/`, treats `.` and `-` as the same character, and removes a trailing date (`-20251001`), `-preview` or `-latest`. Billing, logs and key allow-lists always record the canonical id.

An id that maps to nothing returns `404`: `Model '<id>' is not available. See GET /v1/models.`

## The path of one request {#request-path}

1. **Key check.** An inactive or expired key gets `401`.
2. **Model lookup.** The id is normalized as above and compared with the key's model allow-list; a model outside it gets `403`.
3. **Balance check.** A balance at or below zero, or a key that has used up its spend limit, gets `402`.
4. **Key limits.** Per-key requests-per-minute and concurrency caps apply (`429`). Details in [Rate limits & failover](/docs/models/rate-limits).
5. **Channel choice.** A conversation that is already under way returns to the channel holding its prompt cache; a new one goes to the healthiest channel in its [channel group](/docs/models/channel-groups). See [Prompt caching](/docs/models/caching).
6. **Model call.** A channel that fails before any output is swapped for another, up to three attempts in total. Streams are held back until the first token so the swap is invisible. See [Streaming](/docs/models/streaming).
7. **Metering.** The token counts in the response decide the charge, at half the list price, and it leaves your balance within seconds. See [Pricing](/docs/models/pricing).

## What the gateway changes {#what-changes}

Two edits are made to the body, and nothing else:

1. **`model`.** The gateway writes in the spelling the serving channel expects. As a result, the `model` field you get back can use the vendor's form, such as `claude-opus-5-5`.
2. **Usage in OpenAI streams.** Every streaming Chat Completions request gets `stream_options.include_usage: true` so the call can be billed. Your stream therefore ends with one extra chunk whose `choices` array is empty and which carries `usage`. Make sure your loop tolerates an empty `choices`.

Headers you send are dropped, with three exceptions that are forwarded: `anthropic-version`, `anthropic-beta` and `openai-beta`. If `anthropic-version` is absent, `2023-06-01` is filled in. On Gemini paths, query parameters such as `alt=sse` go through, while `key` is stripped.

Response bodies are returned byte for byte. `content-type` and `cache-control` are kept and the gateway adds its own [headers](#response-headers); vendor request ids and vendor rate-limit headers are not passed on.

If the model refuses the request as invalid (`400`, `404`, `413`, `422`), you receive that status and reason in the error format of the API you called. Any other failure is first retried on another channel. More in [Rate limits & failover](/docs/models/rate-limits) and [Errors](/docs/api/errors).

## Headers on every response {#response-headers}

| Header | Contents |
| --- | --- |
| `x-halfoff-request-id` | This call's id, like `req_6f1c...`. It is the key to the entry in [Logs](/app/logs); quote it when you write to support. |
| `x-halfoff-group` | The [channel group](/docs/models/channel-groups) that answered, such as `gpt-value`. |
| `x-halfoff-channel` | That group's channel label, URL-encoded, such as `Pooled%20capacity`. |
| `x-halfoff-queued-ms` | Present only if the call waited for capacity: the wait in milliseconds. |

The first three are listed in `access-control-expose-headers`, so client-side JavaScript can read them.

## What you can't call {#not-available}

The endpoints in the [table above](#endpoints) are the whole surface for models. In particular, these are not served:

- OpenAI: stored responses (`/v1/responses/{id}`), legacy Completions, embeddings, audio, files and batches. The Responses API itself works for GPT models only.
- Anthropic: Message Batches, Files, and fetching a single model by id.
- Gemini: `cachedContents` (explicit context caching), file uploads, `embedContent` and the Live API.

To wire up a specific client, see [Integrations](/docs/integrations).
