A drop-in, Anthropic-compatible API. If your code already talks to api.anthropic.com, changing the base URL and the API key is usually the only edit required. Every example below is copy-ready, paste it into a terminal and change only the key.
https://private-claude-compatible-ai-gateway.halasai.comx-api-key: <your gateway key>Authorization: Bearer <your gateway key>Where to get a key: keys are issued per application by an operator from the admin console (Applications → create a key). The full key is shown once at creation, store it securely, it looks like sk_accounting_4f9a2b6c8d1e3f50_9c1ae7b6d5f4c3b2a190837465f0e1d2c3b4a5968778695a4b3c2d1e0f1a2b3c, the fixed sk_ marker, then the owning application's slug, a public key id, and a secret. Only the non-secret prefix, sk_accounting_4f9a2b6c8d1e3f50, is retained and shown afterwards; the secret portion is never displayed again. Send the full key as the x-api-key header, or equivalently as Authorization: Bearer. Also send anthropic-version: 2023-06-01 for compatibility. Every response includes an x-request-id header, quote it when reporting an issue (see Response headers below).
A non-streaming completion against the Messages endpoint:
curl https://private-claude-compatible-ai-gateway.halasai.com/v1/messages \
-H "x-api-key: $GATEWAY_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "sonnet",
"max_tokens": 512,
"messages": [
{ "role": "user", "content": "Summarize the CAP theorem in two lines." }
]
}'Point the official Anthropic SDK at the gateway with its base-URL option. Nothing else in your call changes.
import Anthropic from "@anthropic-ai/sdk";
// Point the official Anthropic SDK at your gateway. Only the baseURL and key
// change, every request shape is identical to api.anthropic.com.
const client = new Anthropic({
baseURL: "https://private-claude-compatible-ai-gateway.halasai.com",
apiKey: process.env.GATEWAY_API_KEY, // sent as x-api-key
});
const message = await client.messages.create({
model: "sonnet",
max_tokens: 512,
messages: [{ role: "user", content: "Hello, Claude" }],
});
console.log(message.content);from anthropic import Anthropic
# Point the official Anthropic SDK at your gateway. Only base_url and
# api_key change, every request shape is identical to api.anthropic.com.
client = Anthropic(
base_url="https://private-claude-compatible-ai-gateway.halasai.com",
api_key=os.environ["GATEWAY_API_KEY"], # sent as x-api-key
)
message = client.messages.create(
model="sonnet",
max_tokens=512,
messages=[{"role": "user", "content": "Hello, Claude"}],
)
print(message.content)Your client executes tools, the gateway never does. The gateway forwards tool definitions to the model and streams back its tool_use request; running the function and returning a tool_result is entirely your responsibility.
The four steps, end to end:
// 1. You send the request WITH tool definitions. The gateway forwards them
// to the model; it never executes a tool itself.
{
"model": "sonnet",
"max_tokens": 1024,
"tools": [
{
"name": "get_weather",
"description": "Get the current weather for a city.",
"input_schema": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
],
"messages": [
{ "role": "user", "content": "What's the weather in Riyadh?" }
]
}// 2. The model replies asking to call the tool. stop_reason is "tool_use".
// Note the tool_use id, you must echo it back.
{
"id": "msg_01AbC...",
"role": "assistant",
"stop_reason": "tool_use",
"content": [
{ "type": "text", "text": "Let me check that." },
{
"type": "tool_use",
"id": "toolu_01Xy...",
"name": "get_weather",
"input": { "city": "Riyadh" }
}
]
}// 3. YOUR CODE runs get_weather("Riyadh"), then sends the result back as a
// tool_result whose tool_use_id MATCHES the id from step 2. Replay the prior
// turns so the model has the full context.
{
"model": "sonnet",
"max_tokens": 1024,
"tools": [ /* same tool definitions as step 1 */ ],
"messages": [
{ "role": "user", "content": "What's the weather in Riyadh?" },
{
"role": "assistant",
"content": [
{ "type": "text", "text": "Let me check that." },
{ "type": "tool_use", "id": "toolu_01Xy...", "name": "get_weather", "input": { "city": "Riyadh" } }
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Xy...",
"content": "38°C, clear sky"
}
]
}
]
}// 4. With the tool_result in hand, the model returns the final answer.
{
"id": "msg_01Def...",
"role": "assistant",
"stop_reason": "end_turn",
"content": [
{ "type": "text", "text": "It's 38°C and clear in Riyadh right now." }
]
}Set "stream": true. The response is text/event-stream and follows Anthropic's event lifecycle; bytes are flushed as the runtime produces them.
curl -N https://private-claude-compatible-ai-gateway.halasai.com/v1/messages \
-H "x-api-key: $GATEWAY_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "sonnet",
"max_tokens": 512,
"stream": true,
"messages": [{ "role": "user", "content": "Write a haiku about caching." }]
}'
# Response is text/event-stream, flushed token-by-token:
# event: message_start -> { id, model, role, usage }
# event: content_block_start
# event: content_block_delta -> { delta: { type: "text_delta", text: "..." } }
# event: content_block_stop
# event: message_delta -> { delta: { stop_reason }, usage: { output_tokens } }
# event: message_stopA base64 image block alongside a text block, both in the final message:
curl https://private-claude-compatible-ai-gateway.halasai.com/v1/messages \
-H "x-api-key: $GATEWAY_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "sonnet",
"max_tokens": 512,
"messages": [
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
}
},
{ "type": "text", "text": "What color is this image?" }
]
}
]
}'Every field POST /v1/messages accepts:
modelRequiredstringModel name or alias; see the live list below. An unmapped name returns not_found_error.
messagesRequiredMessageParam[]Conversation turns: { role: 'user' | 'assistant', content: string | ContentBlock[] }. Must be non-empty.
max_tokensRequiredinteger > 0Upper bound on generated tokens for this response.
systemOptionalstring | TextBlock[]System prompt, as a bare string or an array of text blocks.
streamOptionalbooleanSet true for an SSE (text/event-stream) response.
toolsOptionalTool[]Tool definitions forwarded to the model verbatim; the gateway never executes a tool.
tool_choiceOptional'auto' | 'any' | 'tool' | 'none'Controls whether, and which, tool the model must call. 'tool' requires naming a specific tool.
temperatureOptionalnumber, 0-1Sampling temperature.
top_pOptionalnumber, 0-1Nucleus sampling threshold.
top_kOptionalinteger >= 0Top-k sampling cutoff.
stop_sequencesOptionalstring[]Sequences that stop generation when produced.
metadataOptional{ user_id?: string }Optional pseudonymous per-user id for attribution — never a name or an email. See below.
thinkingOptional{ type: 'adaptive' | 'enabled' | 'disabled', ... }Extended-thinking configuration; enabled requires a budget_tokens.
| Field | Type | Required | Notes |
|---|---|---|---|
model | string | Required | Model name or alias; see the live list below. An unmapped name returns not_found_error. |
messages | MessageParam[] | Required | Conversation turns: { role: 'user' | 'assistant', content: string | ContentBlock[] }. Must be non-empty. |
max_tokens | integer > 0 | Required | Upper bound on generated tokens for this response. |
system | string | TextBlock[] | Optional | System prompt, as a bare string or an array of text blocks. |
stream | boolean | Optional | Set true for an SSE (text/event-stream) response. |
tools | Tool[] | Optional | Tool definitions forwarded to the model verbatim; the gateway never executes a tool. |
tool_choice | 'auto' | 'any' | 'tool' | 'none' | Optional | Controls whether, and which, tool the model must call. 'tool' requires naming a specific tool. |
temperature | number, 0-1 | Optional | Sampling temperature. |
top_p | number, 0-1 | Optional | Nucleus sampling threshold. |
top_k | integer >= 0 | Optional | Top-k sampling cutoff. |
stop_sequences | string[] | Optional | Sequences that stop generation when produced. |
metadata | { user_id?: string } | Optional | Optional pseudonymous per-user id for attribution — never a name or an email. See below. |
thinking | { type: 'adaptive' | 'enabled' | 'disabled', ... } | Optional | Extended-thinking configuration; enabled requires a budget_tokens. |
metadata.user_id lets the gateway record a pseudonymous per-user identifier alongside a request for attribution (e.g. per-user usage in the admin console). It must be an OPAQUE identifier you control — a database id or a hash — never a real name or an email address.
Content-block types a message's content array may contain:
textPlain text content.
imagesource: base64 (image/jpeg, image/png, image/gif, image/webp) or a url. Final message only — see Images & documents above.
documentsource: base64 PDF, plain text, or a url; optional title, context, citations. Final message only.
tool_useA prior tool call, replayed unchanged in assistant-turn history.
tool_resultYour reply to a tool_use: tool_use_id, content (string or text/image blocks), optional is_error.
thinkingAn extended-thinking block, echoed back unchanged in assistant history.
redacted_thinkingAn opaque redacted-thinking block, echoed back unchanged.
| type | Notes |
|---|---|
text | Plain text content. |
image | source: base64 (image/jpeg, image/png, image/gif, image/webp) or a url. Final message only — see Images & documents above. |
document | source: base64 PDF, plain text, or a url; optional title, context, citations. Final message only. |
tool_use | A prior tool call, replayed unchanged in assistant-turn history. |
tool_result | Your reply to a tool_use: tool_use_id, content (string or text/image blocks), optional is_error. |
thinking | An extended-thinking block, echoed back unchanged in assistant history. |
redacted_thinking | An opaque redacted-thinking block, echoed back unchanged. |
Any block above may carry cache_control: { "type": "ephemeral", "ttl": "5m" | "1h" } to mark a prompt-caching breakpoint.
Every error uses the Anthropic-style envelope - { "type": "error", "error": { "type", "message" } }. Stack traces are never leaked. Rate-limited and overloaded responses include a retry-after header; honor it with exponential backoff.
400invalid_request_errorMalformed body, failed schema validation, a media block outside the final message, or a per-request media limit exceeded
401authentication_errorMissing, invalid, expired, or revoked API key
403permission_errorKey is valid but not permitted to call the requested model
404not_found_errorUnknown model or unknown route
413request_too_largeRequest body exceeds the configured size ceiling
429rate_limit_errorPer-key rate limit or concurrency ceiling exceeded (honor retry-after)
500api_errorUnexpected internal failure (sanitized)
504timeout_errorRuntime exceeded the configured timeout
529overloaded_errorRuntime saturated or unavailable
| HTTP | error.type | When | Retryable |
|---|---|---|---|
400 | invalid_request_error | Malformed body, failed schema validation, a media block outside the final message, or a per-request media limit exceeded | No |
401 | authentication_error | Missing, invalid, expired, or revoked API key | No |
403 | permission_error | Key is valid but not permitted to call the requested model | No |
404 | not_found_error | Unknown model or unknown route | No |
413 | request_too_large | Request body exceeds the configured size ceiling | No |
429 | rate_limit_error | Per-key rate limit or concurrency ceiling exceeded (honor retry-after) | After backoff |
500 | api_error | Unexpected internal failure (sanitized) | After backoff |
504 | timeout_error | Runtime exceeded the configured timeout | After backoff |
529 | overloaded_error | Runtime saturated or unavailable | After backoff |
Retry guidance:
Three things behave differently from the public Anthropic API:
Only four API surfaces
The gateway implements POST /v1/messages, GET /v1/models, GET /v1/account, and the public health checks. Other api.anthropic.com routes (batches, files, etc.) are not proxied and return not_found_error.
usage token counts may be null
input_tokens / output_tokens come straight from the runtime. When a runtime cannot report them the fields are null, the gateway never fabricates or estimates a count. Treat null as "unknown", not zero.
Model names go through the alias layer
The model you send is resolved through MODEL_ALIASES (e.g. "sonnet" → a real runtime id). An unmapped name returns not_found_error rather than being guessed. The live resolutions are listed below.
POST /v1/messagesAPI key requiredCreate a message. Supports streaming via stream: true.
GET /v1/modelsAPI key requiredLists configured aliases + resolved runtime ids. Returns 401 without a key. Read this list rather than hardcoding model names — it changes as the operator updates aliases.
GET /v1/accountAPI key requiredCaller identity (a non-reversible fingerprint, never a secret) and effective rate limits — useful for a settings screen.
GET /healthPublicLightweight liveness probe.
GET /health/deepPublicReadiness probe (database + runtime). Can report degraded — runtime not initialized on a freshly started container: the runtime initializes lazily on the first /v1/messages call, so this is expected on a cold start, not a fault.
| Endpoint | Auth | Notes |
|---|---|---|
POST /v1/messages | API key required | Create a message. Supports streaming via stream: true. |
GET /v1/models | API key required | Lists configured aliases + resolved runtime ids. Returns 401 without a key. Read this list rather than hardcoding model names — it changes as the operator updates aliases. |
GET /v1/account | API key required | Caller identity (a non-reversible fingerprint, never a secret) and effective rate limits — useful for a settings screen. |
GET /health | Public | Lightweight liveness probe. |
GET /health/deep | Public | Readiness probe (database + runtime). Can report degraded — runtime not initialized on a freshly started container: the runtime initializes lazily on the first /v1/messages call, so this is expected on a cold start, not a fault. |
Every response, success or error, carries these:
x-request-idUnique per request. Quote it verbatim in bug reports — it is the fastest way to find the matching server-side log line.
anthropic-ratelimit-requests-limitYour key's request-rate bucket capacity.
anthropic-ratelimit-requests-remainingWhole tokens left in the current window.
anthropic-ratelimit-requests-resetISO-8601 timestamp the bucket next refills.
retry-afterPresent on 429/529 responses only — seconds to wait before retrying.
| Header | Notes |
|---|---|
x-request-id | Unique per request. Quote it verbatim in bug reports — it is the fastest way to find the matching server-side log line. |
anthropic-ratelimit-requests-limit | Your key's request-rate bucket capacity. |
anthropic-ratelimit-requests-remaining | Whole tokens left in the current window. |
anthropic-ratelimit-requests-reset | ISO-8601 timestamp the bucket next refills. |
retry-after | Present on 429/529 responses only — seconds to wait before retrying. |
Resolved live from GET /v1/models, never hard-coded. Send the left-hand name in the model field; it resolves to the runtime id on the right.
claude-haiku-5-5claude-haiku-5-5claude-sonnet-5-5claude-sonnet-5-5claude-opus-5-5claude-opus-5-5claude-fable-5-1claude-fable-5-1claude-opus-5claude-opus-5claude-sonnet-5claude-sonnet-5claude-fable-5claude-fable-5claude-opus-4-8claude-opus-4-8claude-opus-4-7claude-opus-4-7claude-sonnet-4-6claude-sonnet-4-6claude-opus-4-6claude-opus-4-6claude-opus-4-5-20251101claude-opus-4-5-20251101claude-haiku-4-5-20251001claude-haiku-4-5-20251001claude-sonnet-4-5-20250929claude-sonnet-4-5-20250929claude-haiku-4-5claude-haiku-4-5haikuclaude-haiku-4-5opusclaude-opus-5sonnetclaude-sonnet-5The definitive machine-readable list is always GET /v1/models (authenticated).