Hosted API
The hosted service runs the statistics engine of cci-sdk 0.1.1, unmodified,
behind an authenticated HTTP API. Around it, it adds what a shared service
needs: your own provider credentials stored as encrypted connections, a
calibration lifecycle with background jobs, monitors and audits, idempotent
retries, rate limits, and a trace of every request. This page is the
operational contract; request and response shapes are on
API reference.
Base URL and authentication
https://cci.gitdate.ink/api/v1This is the SDK’s default base_url. Every route except GET /v1/healthz
and GET /v1/readyz needs an API key:
Authorization: Bearer cci_live_...export CLI_API_KEY=cci_live_...
export CLI_BASE_URL=https://cci.gitdate.ink/api/v1 # optional: this is the defaultfrom cli_sdk import CLIClient
client = CLIClient() # reads CLI_API_KEY and CLI_BASE_URLFor a self-hosted deployment, point CLI_BASE_URL at it.
The SDK accepts a missing key only when the URL’s host is localhost,
127.0.0.1, 0.0.0.0 or ::1.
401 code | Meaning |
|---|---|
missing_api_key | No Authorization: Bearer header |
invalid_api_key | The key is unknown |
revoked_api_key | The key was revoked in the dashboard; create a new one |
key_owner_removed | The person who created the key is no longer a member of its organization |
API keys and scopes
Create keys under Dashboard > API keys. The full key is shown once; the service stores only a SHA-256 hash and a short display prefix. A key acts in the organization it was created in, and carries one or more scopes:
| Scope | Grants | Routes |
|---|---|---|
evaluate | evaluate, profiles:read, backends:read | POST /evaluate, GET /jobs, GET /jobs/{id}, every GET on profiles, monitors, alerts, audits and label jobs, GET /backends |
profiles:write | everything evaluate grants, plus profiles:write | Creating, changing and deleting profiles; examples, outcomes, calibrate, audit, label-with-judge, monitors; POST /jobs/{id}/cancel |
telemetry:read | telemetry:read, profiles:read, backends:read | The events read API: GET /requests, GET /requests/{id}, GET /usage, GET /events, GET /calibration-profiles/{name}/events, plus profile reads |
events:write | events:write | POST /events (guard events) and POST /traces (OTLP ingest) |
- New keys default to
evaluate profiles:write. Keys created before scopes existed behave asevaluate profiles:write. - A key never has more than its creator: its effective scopes are its
stored scopes intersected with what the creator’s current role allows.
A viewer can only create
telemetry:readkeys. Role changes apply on the key’s next request. - A key whose creator leaves the organization stops working
(
401 key_owner_removed). - A missing scope is
403 insufficient_scope, withWWW-Authenticate: Bearer error="insufficient_scope", scope="..."naming the scope to add.
Use separate keys per service, so a leaked or revoked key affects one workload, and so Usage shows cost per service.
Bring your own provider key
The hosted service holds no shared provider credential. Every model call
uses a provider connection that your organization stored:
an encrypted OpenAI, Anthropic, Gemini, Bedrock or other key, or the URL of
your own vLLM or SGLang server. Without one, evaluate returns
422 connection_required with field: "backend.connection".
A backend uses the organization’s default connection for its provider, or the one it names:
from cli_sdk import OpenAIBackend
OpenAIBackend(model="gpt-4.1-2025-04-14", connection="openai-prod")
# equivalent dict: {"provider": "openai", "model": "gpt-4.1-2025-04-14", "connection": "openai-prod"}With a CustomBackend the SDK computes
evidence in your process and the server never calls a model, so no
connection is needed.
Providers and access levels
provider | Connection | Level with access_hint="auto" | Highest level |
|---|---|---|---|
openai | openai | L1 on gpt-4.1 and on GPT-5.x and GPT-6 Sol and Luna at reasoning_effort="none" (or unset); L0 otherwise | L1 |
azure-openai | azure-openai | As OpenAI, read from model (the model the deployment serves) | L1 |
anthropic | anthropic | L0 | L0 |
gemini | gemini (API key, or Vertex mode) | L0 | L0 |
vertex | gemini in Vertex mode | L0 | L0 |
bedrock | bedrock | L0 (Converse API) | L0 |
bedrock with custom_model_import=True | bedrock | L1 | L2 with access_hint="prompt-scoring" |
openrouter | openrouter | L1 for an OpenAI model that reaches L1 on its own (for example openai/gpt-4.1) on a pinned openai, together or fireworks upstream; L0 otherwise | L1 |
vllm | vllm | L1 | L3 (L2 prompt-scoring, L3 exact) |
sglang | sglang | L1 | L4 (hidden-state) |
openai-compatible | openai-compatible | L0, or L1 with "use_logprobs": true | L1 |
mistral, groq, xai | one per preset | L0 | L0 |
together, fireworks, deepseek, cerebras | one per preset | L1 | L1 |
client_evidence | none | As your CustomBackend declares |
The levels above auto come from the connection’s capability probe: the
service records what a self-hosted server or an imported Bedrock model
supports when you run Test on the connection. Details and examples per
provider are on Backends and Provider connections.
- A
Setwith more than 20 options cannot be scored from first-token log-probabilities, so it runs at L0 on providers whoseautolevel is L1. - An
access_hint(or aSet’sbackend_access_hint) pins a level:sampling(L0),logprobs(L1),prompt-scoring(L2),exact(L3),hidden-state(L4). A level the backend cannot reach fails with502 access_level_unavailablebefore any model call is made.hidden-stateservesSet,Belief,GateandIntervalqueries. - At L0 each score is built from
sample_countsamples: 8 by default, at most 32, set per backend (AnthropicBackend(sample_count=...), or"sample_count"in a backend dict). The sample count is part of the profile’s fingerprint.
Request ids and correlation
Every response carries x-request-id: req_<16 hex digits>, and every JSON
body includes the same value as "request_id". The SDK exposes it as
result.request_id. Log it next to any decision you act on: it opens the
request’s trace in the dashboard and in
GET /v1/requests/{id}.
To tie a request to your own logs you can send:
x-request-idin the samereq_<16 hex>format. It is adopted unless the service has seen it in the last day, in which case a new id is minted. Do not put it indefault_headers, where every request would share it.- A W3C
traceparentheader. Its trace id becomes the request’s trace id. - The
X-CCI-*correlation headers (session, thread, run name, environment, agent, tool call, tags, metadata), listed on Observability.
Invalid correlation headers never fail a request: they are dropped, and the
request’s trace records a warning naming the header (an evaluate
response also lists it in warnings).
Idempotency
Every POST, PUT, PATCH and DELETE may carry an Idempotency-Key
header (1 to 255 printable ASCII characters). The SDK sends a fresh UUID
for each logical POST and reuses it across that call’s retries, so a
retried write is applied once.
| Situation | Response |
|---|---|
| The key was used before for the same request, within 24 hours | The original status and body, replayed, with Idempotent-Replayed: true |
| The original request with this key is still running | 409 idempotency_in_flight with Retry-After: retry shortly |
| The key was used for a different route or body | 422 idempotency_key_reused: use a new key for a different call |
| The key is not 1 to 255 printable ASCII characters | 422 invalid_idempotency_key |
Keys are scoped to your organization and the API key that sent them.
Responses with status 2xx, 400, 404, 413, 415, 422 and 424
are stored for replay. Responses a retry could change (401, 402, 403,
408, 409, 429, 5xx, 529) are not stored, so a retry runs again.
Rate limits and concurrency
| Limit | Value | When exceeded |
|---|---|---|
| Request rate per API key | Token bucket: bursts of 60, refilled at 10 per second | 429 rate_limited |
Concurrent writes (POST, PUT, PATCH, DELETE) per organization | 16 | 429 concurrency_limited |
| Concurrent writes per API key | 8 | 429 concurrency_limited |
| Requests in flight across the service | bounded | 529 service_overloaded |
Every 429 and 529 carries Retry-After in seconds. Rate-limited routes
also return RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset.
Limits your provider enforces on your own key come back as
429 provider_rate_limited (with the provider’s Retry-After),
529 provider_overloaded, or 402 provider_quota_exhausted.
The SDK retries 429, 502 and 529 with full-jitter exponential
backoff, honoring a numeric Retry-After of up to 60 seconds. It does not
retry 402: an exhausted quota needs attention on the provider account.
See Errors and retries.
Timeouts and background jobs
| Timeout | Value | Result |
|---|---|---|
| The hosted proxy waiting for a response | 300 seconds | 504 gateway_timeout |
| One provider call (connect, read) | 5 s, 60 s | 502 provider_timeout (retried by the SDK) |
| The SDK’s HTTP timeout | CLIClient(timeout=60.0) by default | APIConnectionError after retries |
Raise the SDK’s timeout for synchronous audits and first calibrations,
for example CLIClient(timeout=300). A 504 is not retried by the SDK; it
raises CLIError("HTTP 504: ...").
Work that would take long runs as a persisted background job:
- Calibration. When the first
evaluateagainst a profile needs more than 200 estimated model calls to score its examples, calibration runs as a job. Until it finishes,evaluateanswers with424andheuristic-labelled answers, plus a warning naming the job (the SDK returns them; withstrict_guarantees=Trueit raises). Smaller calibrations run inline, within the request. - Appended examples. Examples added to a calibrated profile are scored
inline when cheap, otherwise by a job; the profile card then shows
pending_nandjob. - Explicit work.
POST .../calibrate, an audit sent with"async": true, and a largelabel-with-judgepool return202 Acceptedwith the job.
Poll a job until it leaves queued and running:
import os
import time
import httpx
API = os.environ.get("CLI_BASE_URL", "https://cci.gitdate.ink/api/v1").rstrip("/")
HEADERS = {"Authorization": f"Bearer {os.environ['CLI_API_KEY']}"}
def wait_for_job(job_id: str, every: float = 5.0) -> dict:
while True:
job = httpx.get(f"{API}/jobs/{job_id}", headers=HEADERS, timeout=30).json()
if job["status"] not in ("queued", "running"):
return job # "succeeded", "failed" or "cancelled"
time.sleep(every)A job has id, kind, status, profile, progress ({done, total}),
estimated_calls, actual_calls, result, error, created_at,
started_at, finished_at and request_id (the request that queued it).
GET /v1/jobs?profile=&status=&limit= lists jobs, and
POST /v1/jobs/{id}/cancel cancels a queued or running one. Jobs survive a
restart of the service: running jobs go back to the queue.
Limits
| Limit | Value | Over the limit |
|---|---|---|
| Request body | 5 MB (20 MB for POST /v1/traces) | 413 payload_too_large |
Queries per evaluate | 16 | 422, field queries |
Options per Set | 256 | 422 |
| Stages per cascade | 6 | 422 |
Examples per add_examples call | 10,000 | 422 payload_too_large, field examples |
| Examples per profile | 100,000 | 422 profile_full |
| Profiles per organization | 1,000 | 422 profile_limit |
| Monitors per profile | 20 | 422 monitor_limit |
| Outcomes per call | 1,000 | 422, field outcomes |
| Examples per audit | 2,000 | 422, field examples |
| Synchronous audit | about 1,500 estimated model calls | 422 audit_too_large: send "async": true |
Unlabelled pool for label-with-judge | 20,000 | 422 |
| Inline calibration | 200 estimated model calls | a background job |
| Samples per score at L0 | 8 by default, at most 32 | 422 |
| Guard events per batch | 500 | 422 |
| Idempotent replay window | 24 hours | the key can be reused |
| Profile versions kept | 10 | older versions are pruned |
Status codes
| Status | Meaning | SDK |
|---|---|---|
200 | OK | |
201 | Created: a new profile (with Location) or monitor | |
202 | Accepted: a job was queued, guard events were accepted, or a label-with-judge result | |
401 | Missing, invalid or revoked API key | AuthenticationError |
402 | provider_quota_exhausted on your provider account | CLIError |
403 | insufficient_scope (API key) or forbidden_role (dashboard role) | CLIError |
404 | Unknown profile, monitor, job, request or route | CLIError |
405 | Method not allowed on this route | CLIError |
409 | profile_exists, idempotency_in_flight, profile_busy | CLIError |
413 | Body too large | CLIError |
415 | Unsupported content type (OTLP ingest) | CLIError |
422 | Validation failed; field names the offending field | ValidationError (.field) |
424 | evaluate: some answers are heuristic (the body holds every answer); other routes: the profile is not calibrated yet | Heuristic answers, or InsufficientCalibrationError |
429 | Rate or concurrency limit, here or at your provider | RateLimitError after retries |
500 | Internal error | CLIError |
502 | The model backend failed or timed out, or the access level is unavailable | BackendError after retries |
503 | The service is not ready (for example its account store is unavailable) | CLIError |
504 | The proxy timed out | CLIError |
529 | The service or your provider is overloaded | BackendError after retries |
Every error body is {"message", "code", "field"?, "details"?, "request_id"}.
The full code catalog is on Errors and retries.
GET /v1/healthz answers {"status": "ok"} while the process is up.
GET /v1/readyz also checks the stores and the telemetry writer and
answers 503 with {"status": "not_ready", "checks": {...}} when one is
unavailable. Neither needs a key.