Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 288 additions & 0 deletions docs/api-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
# Proxy API

The Proxy API is the public, OpenAI-compatible HTTP surface that
caller traffic targets. It is served on the proxy listener (default
`:3000`). For the operator CRUD surface see
[`api-admin.md`](./api-admin.md).

> **Compatibility goal**: any client SDK that targets OpenAI
> (`openai` Python, `openai-node`, `openai-go`, `instructor`, the
> Anthropic SDK against `/v1/messages`, etc.) should work unchanged
> by repointing `base_url` at aisix.

## 1. Authentication

Every endpoint requires a caller API key, presented as either
`Authorization: Bearer <key>` (preferred) or `Authorization: <key>`
(bare-key fallback for legacy SDKs). The key must exist in the
Comment on lines +15 to +17

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication behavior here doesn't match the implementation: the proxy extractor only accepts Authorization: Bearer <key> or x-api-key: <key>; it rejects a bare Authorization: <key> header. Also note GET /health is mounted without auth, so "Every endpoint requires" is not strictly true as written.

Copilot uses AI. Check for mistakes.
`apikeys` table of the current snapshot. See
[architecture.md §3](./architecture.md#3-configuration-data-plane).

```http
Authorization: Bearer sk-aisix-…
```

Authorization is a *separate* check: the resolved `ApiKey` must list
the requested Model in its `allowed_models` array (or contain the
`"*"` wildcard).

## 2. Error envelope

Errors follow the OpenAI shape so SDK error handlers light up:

```json
{
"error": {
"message": "model 'mygpt' not found",
"type": "model_not_found",
"param": null,
"code": null
Comment on lines +37 to +39

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sample error JSON shows param: null and code: null, but the proxy's ErrorBody uses skip_serializing_if for both fields, so they are omitted entirely when unset. To avoid confusing clients comparing responses, update the example to match the actual on-wire shape (or adjust implementation to always emit explicit nulls).

Suggested change
"type": "model_not_found",
"param": null,
"code": null
"type": "model_not_found"

Copilot uses AI. Check for mistakes.
}
}
```

| Status | `type` | When |
|---|---|---|
| 400 | `invalid_request_error` | Malformed body, missing `model`, etc. |
| 401 | `authentication_error` | Missing or unknown bearer key |
| 403 | `model_access_forbidden` | Key valid but Model not in `allowed_models` |
Comment on lines +47 to +48

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type tokens in this status table don't match what the proxy actually emits. For example 401s map to invalid_api_key (not authentication_error), and 403s map to permission_denied (not model_access_forbidden). Consider deriving this table directly from aisix-proxy::ProxyError::kind() to keep docs and behavior in sync.

Suggested change
| 401 | `authentication_error` | Missing or unknown bearer key |
| 403 | `model_access_forbidden` | Key valid but Model not in `allowed_models` |
| 401 | `invalid_api_key` | Missing or unknown bearer key |
| 403 | `permission_denied` | Key valid but Model not in `allowed_models` |

Copilot uses AI. Check for mistakes.
| 404 | `model_not_found` | `req.model` does not resolve in the snapshot |
| 413 | `request_too_large` | Body exceeds `proxy.request_body_limit_bytes` (default 10 MB) |
| 422 | `invalid_request_error` | Schema-valid JSON but semantically wrong (e.g. empty `messages`) |
| 429 | `rate_limit_exceeded` / `concurrency_limit_exceeded` / `budget_exceeded` | RPM/TPM/concurrency/budget cap |
| 502 | `provider_error` | Upstream returned 5xx or invalid wire format |
| 503 | `service_unavailable` | No bridge registered for the resolved Model's provider |
| 504 | `request_timeout` | Upstream exceeded `Model.timeout` ms |
Comment on lines +46 to +55

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More mismatches in this table: 422 is used for content_filter (guardrails), not invalid_request_error (empty messages is a 400). 429s are rate_limit_exceeded (the proxy doesn’t currently emit separate concurrency/token-specific type strings) or budget_exceeded. 503 uses provider_unavailable, and 504 comes from BridgeError::Timeout with type timeout.

Suggested change
| 400 | `invalid_request_error` | Malformed body, missing `model`, etc. |
| 401 | `authentication_error` | Missing or unknown bearer key |
| 403 | `model_access_forbidden` | Key valid but Model not in `allowed_models` |
| 404 | `model_not_found` | `req.model` does not resolve in the snapshot |
| 413 | `request_too_large` | Body exceeds `proxy.request_body_limit_bytes` (default 10 MB) |
| 422 | `invalid_request_error` | Schema-valid JSON but semantically wrong (e.g. empty `messages`) |
| 429 | `rate_limit_exceeded` / `concurrency_limit_exceeded` / `budget_exceeded` | RPM/TPM/concurrency/budget cap |
| 502 | `provider_error` | Upstream returned 5xx or invalid wire format |
| 503 | `service_unavailable` | No bridge registered for the resolved Model's provider |
| 504 | `request_timeout` | Upstream exceeded `Model.timeout` ms |
| 400 | `invalid_request_error` | Malformed body, missing `model`, empty `messages`, etc. |
| 401 | `authentication_error` | Missing or unknown bearer key |
| 403 | `model_access_forbidden` | Key valid but Model not in `allowed_models` |
| 404 | `model_not_found` | `req.model` does not resolve in the snapshot |
| 413 | `request_too_large` | Body exceeds `proxy.request_body_limit_bytes` (default 10 MB) |
| 422 | `content_filter` | Request blocked by guardrails/content filtering |
| 429 | `rate_limit_exceeded` / `budget_exceeded` | RPM/TPM/concurrency/budget cap |
| 502 | `provider_error` | Upstream returned 5xx or invalid wire format |
| 503 | `provider_unavailable` | No bridge registered for the resolved Model's provider |
| 504 | `timeout` | `BridgeError::Timeout`; upstream exceeded `Model.timeout` ms |

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +55

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of rows here describe behaviors that the proxy doesn’t currently implement: it doesn’t emit a dedicated 413 request_too_large based on proxy.request_body_limit_bytes (there’s no body-limit layer and oversize reads generally become 400 invalid_request_error). Also the proxy never sets BridgeContext::deadline from Model.timeout, so 504 timeout errors won’t correspond to Model.timeout as described here.

Suggested change
| 400 | `invalid_request_error` | Malformed body, missing `model`, etc. |
| 401 | `authentication_error` | Missing or unknown bearer key |
| 403 | `model_access_forbidden` | Key valid but Model not in `allowed_models` |
| 404 | `model_not_found` | `req.model` does not resolve in the snapshot |
| 413 | `request_too_large` | Body exceeds `proxy.request_body_limit_bytes` (default 10 MB) |
| 422 | `invalid_request_error` | Schema-valid JSON but semantically wrong (e.g. empty `messages`) |
| 429 | `rate_limit_exceeded` / `concurrency_limit_exceeded` / `budget_exceeded` | RPM/TPM/concurrency/budget cap |
| 502 | `provider_error` | Upstream returned 5xx or invalid wire format |
| 503 | `service_unavailable` | No bridge registered for the resolved Model's provider |
| 504 | `request_timeout` | Upstream exceeded `Model.timeout` ms |
| 400 | `invalid_request_error` | Malformed body, missing `model`, etc.; oversized request bodies currently also surface here rather than as a dedicated 413 |
| 401 | `authentication_error` | Missing or unknown bearer key |
| 403 | `model_access_forbidden` | Key valid but Model not in `allowed_models` |
| 404 | `model_not_found` | `req.model` does not resolve in the snapshot |
| 422 | `invalid_request_error` | Schema-valid JSON but semantically wrong (e.g. empty `messages`) |
| 429 | `rate_limit_exceeded` / `concurrency_limit_exceeded` / `budget_exceeded` | RPM/TPM/concurrency/budget cap |
| 502 | `provider_error` | Upstream returned 5xx or invalid wire format |
| 503 | `service_unavailable` | No bridge registered for the resolved Model's provider |
| 504 | `request_timeout` | Request exceeded an active upstream/proxy deadline; this is not currently driven by `Model.timeout` |

Copilot uses AI. Check for mistakes.

For rate-limit and budget errors the response also carries
`Retry-After: <seconds>` (rate limit) or `Retry-After-Seconds-Header`
(budget) headers when known.
Comment on lines +57 to +59

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proxy only injects Retry-After for ProxyError::RateLimit (see ProxyError::retry_after_secs()); budget_exceeded responses currently do not include any retry header, and there is no Retry-After-Seconds-Header emitted. Please update this section to reflect actual headers or add the missing header behavior in code.

Suggested change
For rate-limit and budget errors the response also carries
`Retry-After: <seconds>` (rate limit) or `Retry-After-Seconds-Header`
(budget) headers when known.
For rate-limit errors, the response may also carry
`Retry-After: <seconds>` when the retry delay is known.
`budget_exceeded` responses currently do not include a retry header.

Copilot uses AI. Check for mistakes.

## 3. Response headers (every endpoint)

| Header | Meaning |
|---|---|
| `x-aisix-call-id` | Server-issued request UUID. Echo this when filing support tickets. |
| `x-aisix-cache` | `hit` if the response came from cache, `miss` otherwise. Absent for streaming responses. |
| `x-ratelimit-limit-{requests,tokens,concurrent}` | Configured caps. |
| `x-ratelimit-remaining-{requests,tokens,concurrent}` | Live counters at end of request. |
| `x-ratelimit-reset-{requests,tokens}` | Unix timestamp when the window resets. |
Comment on lines +61 to +69

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This header list doesn’t match current behavior: x-aisix-call-id, x-aisix-cache, and the x-ratelimit-* headers are injected by the chat handler only, not "every endpoint". Most other endpoints instead expose x-aisix-request-id. Also x-ratelimit-reset-{requests,tokens} are rendered as seconds-until-reset strings like "59s", not a Unix timestamp.

Suggested change
## 3. Response headers (every endpoint)
| Header | Meaning |
|---|---|
| `x-aisix-call-id` | Server-issued request UUID. Echo this when filing support tickets. |
| `x-aisix-cache` | `hit` if the response came from cache, `miss` otherwise. Absent for streaming responses. |
| `x-ratelimit-limit-{requests,tokens,concurrent}` | Configured caps. |
| `x-ratelimit-remaining-{requests,tokens,concurrent}` | Live counters at end of request. |
| `x-ratelimit-reset-{requests,tokens}` | Unix timestamp when the window resets. |
## 3. Response headers
Most endpoints include:
| Header | Meaning |
|---|---|
| `x-aisix-request-id` | Server-issued request UUID. Echo this when filing support tickets. |
Chat-handler responses also include:
| Header | Meaning |
|---|---|
| `x-aisix-call-id` | Server-issued call UUID for the chat request. Echo this when filing support tickets. |
| `x-aisix-cache` | `hit` if the response came from cache, `miss` otherwise. Absent for streaming responses. |
| `x-ratelimit-limit-{requests,tokens,concurrent}` | Configured caps. |
| `x-ratelimit-remaining-{requests,tokens,concurrent}` | Live counters at end of request. |
| `x-ratelimit-reset-{requests,tokens}` | Seconds-until-reset string such as `"59s"`. |

Copilot uses AI. Check for mistakes.
| `Retry-After` | On 429 rate-limit responses only. |

## 4. Endpoints

### 4.1 `GET /v1/models`

Comment on lines +72 to +75

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The router also mounts GET /health on the proxy listener (see crates/aisix-proxy/src/lib.rs), but it’s not documented in the endpoints list. Since the PR description calls this an "endpoint reference for every mounted route", /health should be included (and noted as unauthenticated).

Copilot uses AI. Check for mistakes.
Returns the OpenAI list shape, filtered to the Models the calling key
is allowed to access. Wildcard `*` keys see every Model.

```bash
curl -H "Authorization: Bearer sk-aisix-…" \
http://localhost:3000/v1/models
```

```json
{
"object": "list",
"data": [
{"id": "my-gpt4", "object": "model", "created": 0, "owned_by": "openai"}
]
}
```

### 4.2 `POST /v1/chat/completions`

OpenAI-compatible chat completion. Both streaming and non-streaming
work identically to upstream OpenAI.

**Non-streaming**

```bash
curl -X POST http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer sk-aisix-…" \
-H "Content-Type: application/json" \
-d '{
"model": "my-gpt4",
"messages": [{"role": "user", "content": "hello"}]
}'
```

**Streaming** — set `"stream": true`. The response is `text/event-stream`
with one `data: {chunk-json}` per delta and a final `data: [DONE]`.
Set `"stream_options": {"include_usage": true}` to receive a final
`usage` chunk before `[DONE]`. aisix injects this automatically when
the request omits the field, so client SDKs get accurate token totals
even in streaming mode.
Comment on lines +113 to +115

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs say aisix auto-injects stream_options: {include_usage:true} when omitted, but the proxy/gateway types don’t currently implement this (there’s no stream_options field on ChatFormat, and nothing in the proxy mutates req.extra to add it). Please remove this claim or implement the injection so streaming usage matches the documentation.

Suggested change
`usage` chunk before `[DONE]`. aisix injects this automatically when
the request omits the field, so client SDKs get accurate token totals
even in streaming mode.
`usage` chunk before `[DONE]`.

Copilot uses AI. Check for mistakes.

**Tool calls**, **JSON mode**, **vision content blocks**, and
**function-style tool definitions** all pass through unchanged.
Comment on lines +117 to +118

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "passes through unchanged" claim is not accurate for the current request schema: ChatMessage has #[serde(deny_unknown_fields)] and content: String, so OpenAI-style content blocks (array/object content) and message-level fields like tool_calls will fail deserialization at the proxy boundary. The docs should call out these limitations (or the request types need to be widened to support those shapes).

Suggested change
**Tool calls**, **JSON mode**, **vision content blocks**, and
**function-style tool definitions** all pass through unchanged.
**Compatibility note** — the current chat request schema does **not**
accept every OpenAI request shape unchanged. In particular,
`messages[].content` must be a string, so OpenAI-style multimodal /
vision content blocks (array/object content) are rejected at the proxy
boundary, and unknown message-level fields such as `tool_calls` are
also rejected. Top-level options such as **JSON mode** and
**function-style tool definitions** may still be forwarded when they
match the accepted request schema.

Copilot uses AI. Check for mistakes.

**Caching** — non-streaming requests with the same fingerprint
(model + messages + temperature + top_p + max_tokens) hit the cache.
Override per request with `Cache-Control` header values:

| Header value | Effect |
|---|---|
| `no-store` | Skip cache lookup AND skip storing the response |
| `no-cache` | Skip lookup but still store on success |
| `s-maxage=N` | Override TTL for this entry |
Comment on lines +120 to +128

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-request cache controls described here (Cache-Control: no-store|no-cache|s-maxage) aren’t implemented in the chat handler: caching is currently unconditional for non-streaming requests when state.cache is enabled, and the handler never reads the incoming Cache-Control header. Please either implement these semantics or adjust the docs to describe the current behavior.

Suggested change
**Caching** — non-streaming requests with the same fingerprint
(model + messages + temperature + top_p + max_tokens) hit the cache.
Override per request with `Cache-Control` header values:
| Header value | Effect |
|---|---|
| `no-store` | Skip cache lookup AND skip storing the response |
| `no-cache` | Skip lookup but still store on success |
| `s-maxage=N` | Override TTL for this entry |
**Caching** — when proxy caching is enabled, non-streaming requests
with the same fingerprint (model + messages + temperature + top_p +
max_tokens) hit the cache.
Per-request cache overrides via the `Cache-Control` request header are
not currently supported by the chat handler. In particular,
`no-store`, `no-cache`, and `s-maxage=N` request directives are not
implemented.

Copilot uses AI. Check for mistakes.

### 4.3 `POST /v1/completions`

Legacy OpenAI Completions (text-in / text-out). Same auth, error,
and header semantics as chat.

### 4.4 `POST /v1/embeddings`

Forwards to the configured Model's `/v1/embeddings` endpoint.
`input` may be a single string or an array; both pass through.

```json
{"model": "my-embeddings", "input": ["foo", "bar"]}
```

### 4.5 `POST /v1/messages` (Anthropic native)

Native Anthropic Messages API path. The body is forwarded with the
`model` field rewritten to the upstream Anthropic model id. Use this
when your client already speaks Anthropic and you want zero
translation overhead.

### 4.6 `POST /v1/responses` (OpenAI Responses)

Native OpenAI Responses API. OpenAI Models only — non-OpenAI providers
return 400.

### 4.7 `POST /v1/rerank`

Cohere-style rerank. Routed to `{base}/v1/rerank`. The Model's
provider supplies the API key; the request body is forwarded
verbatim after rewriting the `model` field.

### 4.8 `POST /v1/audio/transcriptions` / `translations` / `speech`

Multipart file upload + JSON pass-through. `audio/speech` returns a
binary audio stream; the others return JSON with text + segments.

### 4.9 `POST /v1/images/generations`

OpenAI Images API. Forwarded with the `model` field rewritten.

### 4.10 `ANY /passthrough/{provider}/*rest`

Lowest-overhead escape hatch: aisix injects the configured provider
API key (and Anthropic's `x-api-key` + `anthropic-version` headers
when applicable) and forwards the request verbatim. Useful for
provider endpoints we haven't yet wrapped natively (e.g. OpenAI's
batches API, files, fine-tuning).

```bash
curl -X POST http://localhost:3000/passthrough/openai/v1/batches \
-H "Authorization: Bearer sk-aisix-…" \
-H "Content-Type: application/json" \
-d '{...}'
```

The provider segment must match a configured Model's provider prefix
(`openai`, `anthropic`, `gemini`, `deepseek`). aisix picks the first
Model with that prefix and uses its credentials.

## 5. Streaming protocol details

aisix preserves the upstream SSE wire format byte-for-byte where
possible:

- One `data:` line per chunk, terminated by `\n\n`.
- A keepalive comment (`: ping`) is emitted every 15 s of idle time
to prevent intermediate proxies from dropping the connection.
- The terminal `data: [DONE]` is always sent on a clean upstream
finish, even if the upstream omitted it.
- If the upstream stream terminates abnormally, aisix sends a final
error chunk and closes the response without `[DONE]`. Client SDKs
that interpret missing `[DONE]` as an error will surface the right
error class.
Comment on lines +198 to +203

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The streaming failure semantics described here don’t match the implementation: build_sse_stream always appends a final data: [DONE] after the upstream stream ends, even if it previously yielded an error (it emits event: error chunks but still sends [DONE]). Please update this section or adjust the stream builder to omit [DONE] on error if that behavior is required.

Suggested change
- The terminal `data: [DONE]` is always sent on a clean upstream
finish, even if the upstream omitted it.
- If the upstream stream terminates abnormally, aisix sends a final
error chunk and closes the response without `[DONE]`. Client SDKs
that interpret missing `[DONE]` as an error will surface the right
error class.
- The terminal `data: [DONE]` is sent when the stream builder reaches
end-of-stream, including clean upstream completion and cases where
an upstream error was already emitted as an SSE error event/chunk.
- If the upstream stream terminates abnormally, aisix emits an SSE
error event/chunk before the response closes. Clients should treat
that error event as authoritative rather than relying on absence of
`[DONE]` to detect failure.

Copilot uses AI. Check for mistakes.

## 6. Provider-specific notes

| Provider | Native endpoint | OpenAI-translated endpoint | Notes |
|---|---|---|---|
| OpenAI | `/v1/chat/completions`, `/v1/responses` | (no translation needed) | aisix auto-injects `stream_options.include_usage = true` |
| Anthropic | `/v1/messages` | `/v1/chat/completions` (full translation) | The Hub maps content blocks ↔ messages, tool_use ↔ tool_calls, system extraction, cache_control passthrough, stop_reason normalisation |
| Gemini | `/v1/chat/completions` (OpenAI-compat endpoint) | (same) | Uses Gemini's OpenAI-compatible base URL with `x-goog-api-key` auth |
| DeepSeek | `/v1/chat/completions` (OpenAI-compat endpoint) | (same) | Uses Bearer auth, OpenAI-compatible payloads |

For `gemini` and `deepseek` there is no separate "native" endpoint —
both providers expose OpenAI-compatible APIs and the Bridge is a thin
auth + base-URL wrapper.

## 7. Worked example: OpenAI Python SDK

```python
from openai import OpenAI

client = OpenAI(
base_url="http://localhost:3000/v1",
api_key="sk-aisix-…",
)

# Non-streaming
resp = client.chat.completions.create(
model="my-gpt4",
messages=[{"role": "user", "content": "hello"}],
)
print(resp.choices[0].message.content)

# Streaming
for chunk in client.chat.completions.create(
model="my-gpt4",
messages=[{"role": "user", "content": "hello"}],
stream=True,
):
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
```

## 8. Worked example: Anthropic SDK against `/v1/messages`

```python
from anthropic import Anthropic

client = Anthropic(
base_url="http://localhost:3000",
api_key="sk-aisix-…",
)

msg = client.messages.create(
model="my-claude",
max_tokens=1024,
messages=[{"role": "user", "content": "hello"}],
)
print(msg.content[0].text)
```

The Anthropic SDK appends `/v1/messages` itself, hence the
`base_url` does *not* include `/v1`.

## 9. Versioning

The proxy surface is versioned at the **path** level (`/v1/...`).
Within a major version aisix follows OpenAI's compatibility rules:

- New optional request fields are accepted and forwarded.
- New optional response fields appear without breaking existing
parsers.
- Removed fields stay accepted (but ignored) for at least one minor
version.

When OpenAI ships a v2 surface, aisix will mount it under `/v2/...`
in parallel for at least one release.

## 10. See also

- [`architecture.md`](./architecture.md) — how the data and request
paths fit together internally.
- [`api-admin.md`](./api-admin.md) — operator CRUD surface.
- The auto-generated OpenAPI spec lives at `/openapi` on the admin
listener. It is the canonical machine-readable contract; this
document is the human-readable companion.
Comment on lines +283 to +288

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc links to ./architecture.md and ./api-admin.md, but those files don't exist anywhere in this repo (the docs/ directory only contains api-proxy.md). This will render as broken links in GitHub; either add the referenced docs or update these links to point at the correct existing locations.

Copilot uses AI. Check for mistakes.
Loading