Skip to content

Latest commit

 

History

History
717 lines (546 loc) · 22.6 KB

api.md

File metadata and controls

717 lines (546 loc) · 22.6 KB

API Reference

Rook exposes an OpenAI-compatible HTTP API on the configured host:port (default: 127.0.0.1:3773).

Endpoints

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

POST /v1/chat/completions

OpenAI-compatible chat completions endpoint.

Request

{
  "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

Message Object

{
  "role": "user",
  "content": "Hello, world!"
}

Valid roles: system, user, assistant, developer

Response (success)

{
  "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
  }
}

Response (error — 503 All Providers Unavailable)

{
  "error": {
    "type": "internal_error",
    "code": "all_providers_exhausted",
    "message": "All providers failed or are unavailable"
  }
}

Response (error — 429 Rate Limited)

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"
  }
}

Response (error — 500 Internal)

{
  "error": {
    "type": "internal_error",
    "code": null,
    "message": "provider error: connection timeout"
  }
}

Routing errors — POST /v1/chat/completions wire shapes

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"}

GET /v1/models

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"}
  ]
}

POST /v1/messages

Anthropic-compatible messages endpoint.

Request

{
  "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

Response (success)

{
  "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
  }
}

Response (error — 503)

All providers unavailable

Response (error — 500)

provider error: connection timeout

Routing errors — POST /v1/messages wire shapes

Unlike 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"}

GET /health

Aggregated health status of all configured providers with circuit breaker state.

Response

{
  "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

GET /api/resilience

Detailed circuit breaker state for all providers. Requires session authentication.

Response

{
  "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

Authentication

Rook uses session-based authentication for management endpoints (/api/*). The dashboard UI handles this automatically.

CSRF Protection

All state-changing requests (POST, PUT, DELETE) require CSRF protection via the double-submit cookie pattern:

  1. GET /login —获取 CSRF token and csrf_token cookie
  2. POST /login — Include both csrf_token cookie and X-CSRF-Token header with the token value

Login Flow

# 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"}'

CLI: Seed Admin 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 mypassword

GET /api/api-keys

List all API keys with pagination. Requires authenticated session.

Query Parameters

Parameter Type Default Description
limit integer 20 Max keys to return (1-100)
offset integer 0 Number of keys to skip

Response

{
  "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
  }
}

POST /api/api-keys

Create a new API key. The plaintext key is returned only once — save it securely.

Request

{
  "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

Response (201 Created)

{
  "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: The plaintextKey is shown only once at creation time. Store it securely — it cannot be retrieved again.


GET /api/api-keys/{id}

Get details for a specific API key.

Response

{
  "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
}

PUT /api/api-keys/{id}

Update an API key's metadata.

Request

All fields are optional (only supplied fields are updated):

{
  "label": "updated-name",
  "scopes": ["read"],
  "tier": "pro",
  "isActive": false,
  "expiresAt": "2026-12-31T23:59:59Z"
}

Response

Returns the updated API key record (same format as GET).


DELETE /api/api-keys/{id}

Revoke an API key (soft delete). Sets isActive=false and revokedAt=now().

Response

204 No Content on success.

Revoked keys cannot be re-activated. Create a new key instead.


Error Response Format

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
  }
}

Rate Limiting Headers

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.

CORS

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.

OAuth Callback URLs

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 equals 443). 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.

Model Alias Namespace

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.

Request IDs

Each incoming request is assigned a rook-{uuid} ID for tracing. Audit entries record the original request ID for correlation.


Rate Limit Admin API

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.

GET /api/rate-limits

List all rate limit rules.

Response:

[
  {
    "id": "rl_key_a1b2c3d4",
    "scope": "api-key",
    "target": "key_abc123",
    "requests_per_minute": 120,
    "requests_per_day": 5000
  }
]

POST /api/rate-limits

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:

  • target cannot be empty
  • IP-address targets must be valid IPv4 or IPv6 addresses; invalid values return 400 Bad Request with code VALIDATION_ERROR. Accepted addresses are stored and returned in canonical form (for example, 2001:0DB8:0:0:0:0:0:1 becomes 2001:db8::1). Equivalent spellings identify the same rule.
  • scope: "global" must have target: "global"
  • requests_per_minute must be greater than 0
  • Non-null tokens_per_minute is rejected with 400 Bad Request and code UNSUPPORTED_LIMIT.
  • Only one rule can exist for each (scope, target) pair; duplicates return 409 Conflict with code DUPLICATE_RULE.

PUT /api/rate-limits/:id

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 /api/rate-limits/:id

Delete a rate limit rule.

Response: 204 No Content

GET /api/rate-limits/:scope/:target/status

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"
}

Rate Limit Headers

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
}