Rook exposes an OpenAI-compatible HTTP API on the configured host:port (default: 127.0.0.1:3773).
| Method | Path | Description | Auth |
|---|---|---|---|
POST |
/v1/chat/completions |
OpenAI-compatible completions | None |
GET |
/v1/models |
List available models (dynamic) | None |
POST |
/v1/messages |
Anthropic-compatible messages | None |
GET |
/health |
Health check with circuit state | None |
GET |
/api/resilience |
Detailed circuit breaker state | Session |
POST |
/api/resilience/:provider/reset |
Reset circuit for a provider | Session |
GET |
/api/api-keys |
List API keys (paginated) | Session |
POST |
/api/api-keys |
Create API key | Session |
GET |
/api/api-keys/{id} |
Get API key details | Session |
PUT |
/api/api-keys/{id} |
Update API key | Session |
DELETE |
/api/api-keys/{id} |
Revoke API key (soft delete) | Session |
GET |
/login |
Get CSRF token for login | None |
POST |
/login |
Login (create session) | None |
POST |
/logout |
Logout (revoke session) | Session |
OpenAI-compatible chat completions endpoint.
{
"model": "gpt-4o",
"messages": [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"}
],
"stream": false,
"max_tokens": 1024,
"temperature": 0.7
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
model |
string | Yes | — | Model ID (e.g., gpt-4o) or virtual combo rook/<alias> (no X-Rook-Combo together: 400 AMBIGUOUS_ROUTE) |
messages |
array | Yes | — | Array of message objects |
stream |
bool | No | false |
Enable streaming (SSE; combo fallback only before first event) |
max_tokens |
integer | No | — | Maximum tokens to generate |
temperature |
float | No | — | Sampling temperature (0.0–2.0) |
n |
integer | No | 1 |
Ignored for now |
{
"role": "user",
"content": "Hello, world!"
}Valid roles: system, user, assistant, developer
{
"id": "rook-{uuid}",
"object": "chat.completion",
"created": 1735689600,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you today?"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 11,
"total_tokens": 23
}
}{
"error": {
"type": "internal_error",
"code": "all_providers_exhausted",
"message": "All providers failed or are unavailable"
}
}Returns Retry-After header with retry delay in seconds.
{
"error": {
"type": "rate_limit_exceeded",
"code": "rate_limited",
"message": "rate limited by openai-primary, retry after 30s"
}
}{
"error": {
"type": "internal_error",
"code": null,
"message": "provider error: connection timeout"
}
}combo_id_from_headers failures return the generic HttpError shape
({"error": "<message>", "code": "<CODE>"}), not the OpenAI error.type shape:
// 400 model 'rook/coding' + X-Rook-Combo together
{"error": "model selects a virtual combo and X-Rook-Combo is also present; send only one (code: ambiguous_route)", "code": "AMBIGUOUS_ROUTE"}Unknown virtual alias is resolution (usecase 404), and differs between non-streaming and streaming paths:
// 404 non-streaming: OpenAI error shape, lowercase code
{"error": {"type": "invalid_request_error", "code": "model_not_found", "message": "...", "param": "model"}}
// 404 streaming: generic HttpError shape, uppercase code
{"error": "...", "code": "MODEL_NOT_FOUND"}Returns the models available to the caller: registered provider models as
{provider}/{model} plus accessible virtual rook/* combo aliases
(owned_by: "combo"). Filtered by the caller's key restrictions when present.
{
"object": "list",
"data": [
{"id": "openai-primary/gpt-4o", "object": "model", "created": 0, "owned_by": "openai-primary"},
{"id": "rook/coding", "object": "model", "created": 0, "owned_by": "combo"}
]
}Anthropic-compatible messages endpoint.
{
"model": "claude-opus-4-5",
"messages": [
{"role": "user", "content": "Hello"}
],
"max_tokens": 1024,
"temperature": 0.7
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
model |
string | Yes | — | Model ID or virtual combo rook/<alias> (unknown → 404 model_not_found) |
messages |
array | Yes | — | Array of message objects |
stream |
bool | No | false |
Enable streaming (SSE; combo fallback only before first event) |
max_tokens |
integer | Yes | — | Must be >= 1 |
temperature |
float | No | — | Sampling temperature |
{
"id": "rook-{uuid}",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "Hello! How can I help you today?"
}
],
"model": "claude-opus-4-5",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 10,
"output_tokens": 12
}
}All providers unavailableprovider error: connection timeoutUnlike OpenAI, the non-streaming Anthropic handler returns bare bodies (no JSON envelope) for routing failures:
// 400 model 'rook/coding' + X-Rook-Combo together: HttpError JSON
{"error": "model selects a virtual combo and X-Rook-Combo is also present; send only one (code: ambiguous_route)", "code": "AMBIGUOUS_ROUTE"}
// 404 unknown virtual alias, non-streaming: bare 404 text body (no JSON)
unknown virtual model 'rook/unknown'
// 404 unknown virtual alias, streaming: generic HttpError JSON
{"error": "...", "code": "MODEL_NOT_FOUND"}
Aggregated health status of all configured providers with circuit breaker state.
{
"status": "healthy",
"providers": [
{
"id": "openai-primary",
"healthy": true,
"latency_ms": 45,
"last_error": null,
"circuit_state": "closed",
"failure_count": 0,
"cooldown_until": null
},
{
"id": "anthropic-primary",
"healthy": false,
"latency_ms": null,
"last_error": "HTTP 401",
"circuit_state": "open",
"failure_count": 3,
"cooldown_until": "2026-06-04T10:45:30Z"
}
]
}Status values:
"healthy"— all providers are healthy"degraded"— some providers are unhealthy"no_providers_configured"— no providers are configured
Provider fields:
| Field | Type | Description |
|---|---|---|
id |
string | Provider identifier |
healthy |
boolean | Whether the provider is currently healthy |
latency_ms |
integer | Last health check latency in milliseconds |
last_error |
string | Last error message, or null if healthy |
circuit_state |
string | "closed" or "open" — circuit breaker state |
failure_count |
integer | Number of consecutive failures |
cooldown_until |
string | ISO 8601 timestamp when circuit will attempt recovery, or null if closed |
Detailed circuit breaker state for all providers. Requires session authentication.
{
"circuit_states": [
{
"provider": "openai-primary",
"failures": 0,
"state": "closed",
"last_failure": null,
"cooldown_until": null,
"rate_limit_reset": null
},
{
"provider": "anthropic-primary",
"failures": 3,
"state": "open",
"last_failure": "2026-06-04T10:45:00Z",
"cooldown_until": "2026-06-04T10:45:30Z",
"rate_limit_reset": 1717499130
}
]
}Authentication: Requires valid session cookie (same as /api/* management endpoints).
Response fields:
| Field | Type | Description |
|---|---|---|
provider |
string | Provider identifier |
failures |
integer | Number of consecutive failures |
state |
string | "closed" or "open" — circuit breaker state |
last_failure |
string | ISO 8601 timestamp of last failure, or null |
cooldown_until |
string | ISO 8601 timestamp when circuit will attempt recovery, or null |
rate_limit_reset |
integer | Unix epoch seconds when rate limit resets, or null |
Rook uses session-based authentication for management endpoints (/api/*). The dashboard UI handles this automatically.
All state-changing requests (POST, PUT, DELETE) require CSRF protection via the double-submit cookie pattern:
GET /login—获取 CSRF token andcsrf_tokencookiePOST /login— Include bothcsrf_tokencookie andX-CSRF-Tokenheader with the token value
# 1. Get CSRF token
curl -c cookies.txt http://localhost:3773/login
# 2. Login (extract token from response body or cookies.txt)
curl -X POST http://localhost:3773/login \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <token-from-step-1>" \
-b cookies.txt \
-d '{"username":"admin","password":"your-password"}'For initial setup or E2E testing, use the seed-admin CLI command to set the admin password:
# Set admin password
rook seed-admin <password>
# Requires ROOK_CONFIG and API_KEY_HASH_SECRET environment variables
ROOK_CONFIG=/path/to/rook.toml API_KEY_HASH_SECRET="secret" rook seed-admin mypasswordList all API keys with pagination. Requires authenticated session.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 20 |
Max keys to return (1-100) |
offset |
integer | 0 |
Number of keys to skip |
{
"keys": [
{
"id": "key_abc123",
"label": "opencode-agent",
"keyPrefix": "rk_abc1",
"scopes": ["read", "write"],
"tier": "free",
"isActive": true,
"revokedAt": null,
"expiresAt": null,
"createdAt": "2025-05-31T12:00:00Z",
"lastUsedAt": null
}
],
"pagination": {
"total": 1,
"limit": 20,
"offset": 0
}
}Create a new API key. The plaintext key is returned only once — save it securely.
{
"label": "my-agent",
"scopes": ["read", "write"],
"tier": "free",
"expiresAt": null
}| Field | Type | Required | Description |
|---|---|---|---|
label |
string | Yes | Human-readable name |
scopes |
string[] | Yes | Permissions: "read", "write" |
tier |
string | Yes | "free", "pro", "enterprise" |
expiresAt |
string | No | ISO 8601 timestamp, null for no expiry |
{
"key": {
"id": "key_abc123",
"label": "my-agent",
"keyPrefix": "rk_abc1",
"scopes": ["read", "write"],
"tier": "free",
"isActive": true,
"revokedAt": null,
"expiresAt": null,
"createdAt": "2025-05-31T12:00:00Z",
"lastUsedAt": null
},
"plaintextKey": "rk_Nk7_PBl1teOf9z01kPXWR-sPLlTmwhB..."
}
⚠️ Important: TheplaintextKeyis shown only once at creation time. Store it securely — it cannot be retrieved again.
Get details for a specific API key.
{
"id": "key_abc123",
"label": "my-agent",
"keyPrefix": "rk_abc1",
"scopes": ["read", "write"],
"tier": "free",
"isActive": true,
"revokedAt": null,
"expiresAt": null,
"createdAt": "2025-05-31T12:00:00Z",
"lastUsedAt": null
}Update an API key's metadata.
All fields are optional (only supplied fields are updated):
{
"label": "updated-name",
"scopes": ["read"],
"tier": "pro",
"isActive": false,
"expiresAt": "2026-12-31T23:59:59Z"
}Returns the updated API key record (same format as GET).
Revoke an API key (soft delete). Sets isActive=false and revokedAt=now().
204 No Content on success.
Revoked keys cannot be re-activated. Create a new key instead.
All errors follow OpenAI-style format for /v1/chat/completions:
{
"error": {
"type": "internal_error",
"code": "optional_error_code",
"message": "Human-readable error message",
"param": null
}
}When a provider returns a rate limit error, Rook returns:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json
The Retry-After header value is derived from the provider's rate limit response. If not available, defaults to 60 seconds. Dynamic provider connections also enforce their configured max_concurrent; when local capacity is exhausted, requests can return 429 Too Many Requests without starting an upstream request or marking the provider unhealthy.
Set ALLOWED_ORIGINS before starting Rook to a comma-separated list of browser origins (scheme://host[:port]). Whitespace around entries is trimmed. CORS compares each entry exactly with the request's Origin header: scheme, host spelling, and port must match; subdomains, trailing slashes, and other localhost ports are not implicitly allowed, including in debug builds.
When unset, the allowed origins are http://localhost:3000, http://localhost:4747, and http://127.0.0.1:3000. Setting the variable replaces these defaults. An empty value allows no cross-origin browser access. Same-origin requests do not require a CORS allowance.
Wildcard (*) entries are unsupported and fail closed: if ALLOWED_ORIGINS contains *, no cross-origin browser access is allowed and Rook logs a warning. Use explicit origins only. Exact matches receive Access-Control-Allow-Origin, Access-Control-Allow-Credentials: true, and Vary: Origin; unlisted origins receive neither Access-Control-Allow-Origin nor Access-Control-Allow-Credentials. CORS does not replace authentication or reject the HTTP request itself.
Preflight OPTIONS requests return 204 No Content. Allowed methods are GET, POST, PUT, DELETE, OPTIONS; allowed request headers are Content-Type, Authorization, X-API-Key; the preflight cache lifetime is 86400 seconds.
See browser and OAuth configuration for deployment examples.
Authorization-code flows started through POST /api/oauth/{provider}/authorize validate redirectUri before returning an authorization URL:
- The URL must be absolute, use the exact path
/api/oauth/callback, and contain no username, password, query, or fragment. - Non-loopback callbacks require HTTPS and an explicit origin in
ALLOWED_ORIGINS. Matching uses scheme, case-insensitive host, and effective port (omitted HTTPS port equals443). Different ports and subdomains require separate entries. localhost,127.0.0.1, and[::1]callbacks may use HTTP or HTTPS on any port without an allowlist entry. The path and other URL restrictions still apply. This exception does not expand the CORS allowlist.*is unsupported for CORS and does not authorize OAuth callbacks. Configure each HTTPS callback origin explicitly.
Invalid or unlisted callbacks return 400 Bad Request with code VALIDATION_ERROR and message OAuth operation failed. Register the same callback URL with your OAuth provider.
rook/* names are reserved for virtual combo aliases. Creating an ordinary model alias through POST /api/models/aliases with a rook/ prefix returns 400 Bad Request with code INVALID_ALIAS. This check ignores case and surrounding whitespace. Virtual combo requests, including streams, enforce the API key's model and provider restrictions and may return 403 Forbidden when access is denied.
Each incoming request is assigned a rook-{uuid} ID for tracing. Audit entries record the original request ID for correlation.
When [rate_limiting].enabled = true, admin users can manage custom request-ingress rate limit rules via /api/rate-limits endpoints. Active global, API-key, and IP-address rules are enforced for authenticated CLIENT_API requests to /v1/*; the most restrictive matching rule applies. The admin API itself is excluded from these rules to prevent administrative lockout. This endpoint enforces requests per minute and requests per day only; token-per-minute limits are unsupported here because request token usage is not coupled to this ingress limiter. Provider-aware token limits belong to provider routing and usage accounting.
All endpoints require admin session authentication.
List all rate limit rules.
Response:
[
{
"id": "rl_key_a1b2c3d4",
"scope": "api-key",
"target": "key_abc123",
"requests_per_minute": 120,
"requests_per_day": 5000
}
]Create a new rate limit rule.
Request:
{
"scope": "api-key",
"target": "key_abc123",
"requests_per_minute": 120,
"requests_per_day": 5000
}| Field | Type | Required | Description |
|---|---|---|---|
scope |
enum | Yes | "api-key", "ip-address", or "global" |
target |
string | Yes | API key ID, IP address, or "global" |
requests_per_minute |
u32 | Yes | Maximum requests per minute (must be > 0) |
requests_per_day |
u32 | No | Maximum requests per day |
tokens_per_minute |
u32 | No | Unsupported; non-null values are rejected with UNSUPPORTED_LIMIT |
Response: 201 Created with the created rule including generated id.
Validation:
targetcannot be empty- IP-address targets must be valid IPv4 or IPv6 addresses; invalid values return
400 Bad Requestwith codeVALIDATION_ERROR. Accepted addresses are stored and returned in canonical form (for example,2001:0DB8:0:0:0:0:0:1becomes2001:db8::1). Equivalent spellings identify the same rule. scope: "global"must havetarget: "global"requests_per_minutemust be greater than 0- Non-null
tokens_per_minuteis rejected with400 Bad Requestand codeUNSUPPORTED_LIMIT. - Only one rule can exist for each
(scope, target)pair; duplicates return409 Conflictwith codeDUPLICATE_RULE.
Update an existing rate limit rule.
Request: all fields are optional. tokens_per_minute may be omitted or null; a non-null value is rejected.
{
"requests_per_minute": 200,
"requests_per_day": 10000
}Validation: A non-null tokens_per_minute returns 400 Bad Request with code UNSUPPORTED_LIMIT. Omit the field or send null to leave it unset.
Response: 200 OK with the updated rule.
Delete a rate limit rule.
Response: 204 No Content
Get current counters and active request limits for a specific target. Returns 404 Not Found when that target has no rule. reset_at is the next minute boundary unless the daily limit is exhausted, in which case it is the next day boundary (the later reset applies when both limits are exhausted).
Use api_key, ip_address, or global for the status URL's scope segment. Use the canonical IP returned at creation for IP status lookups.
reset_at is the next minute boundary unless the daily quota is exhausted, in which case it is midnight UTC at the start of the next day. Daily exhaustion persists across minute boundaries. The request limiter uses the same daily reset for Retry-After and X-Admin-RateLimit-Reset when RPD blocks.
Example: GET /api/rate-limits/api_key/key_abc123/status
Response:
{
"scope": "api-key",
"target": "key_abc123",
"current_minute_count": 45,
"current_day_count": 2300,
"remaining_minute": 55,
"remaining_day": 2700,
"active_requests_per_minute": 120,
"active_requests_per_day": 5000,
"reset_at": "2026-06-03T13:04:00Z"
}All API responses include rate limit metadata headers:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests in current window |
X-RateLimit-Remaining |
Remaining requests in current window |
X-RateLimit-Reset |
Unix timestamp when limit resets |
When an administrator-defined ingress rule matches, responses also include X-Admin-RateLimit-Limit, X-Admin-RateLimit-Remaining, and X-Admin-RateLimit-Reset. These separate headers preserve the built-in authz tier limiter metadata. Requests without a matching custom rule do not receive these custom headers.
When rate limited (HTTP 429):
| Header | Description |
|---|---|
Retry-After |
Seconds until limit resets |
Example 429 response:
{
"error": "rate_limit_exceeded",
"message": "API key rate limit exceeded. Please try again later.",
"code": "RATE_LIMITED",
"retry_after": 42
}