PerdurancePerdurance

API reference

The submission and retrieval routes for durable LLM inference, SSE replay with Last-Event-ID, and the record a request leaves behind.

Every route below is scoped to a tenancy and a namespace:

https://api.perdurance.dev/{tenancy}/{namespace}/v1

and authenticated with an API key issued for that namespace:

Authorization: Bearer sar_ab12cd34_...

A key is sar_<prefix>_<secret>. The eight-character prefix is the public half — it is what listings and log lines name a key by, and the secret is stored only as a hash.

The administration routes the console drives — namespaces, backends, routing rules, users and keys — are in Administration. Every status this API can return is in Errors.

Submission

RouteDialectAnswers
POST /chat/completionsOpenAIThe connection is held: a completed response, or SSE of chat.completion.chunk when the body says "stream": true
POST /messagesAnthropicThe connection is held: a completed message, or SSE of Anthropic's own events when streaming
POST /requests?dialect=Named by the parameter202 with the request id, immediately

The request body is forwarded to the provider byte for byte, except the model id a routing rule rewrites. The answer comes back exactly as the provider sent it, with no envelope — which is what lets an unmodified vendor SDK work against these routes.

Dialect is decided by the route, never sniffed

dialect on POST /requests takes openai or anthropic, and defaults to openai. Nothing inspects the body to guess.

When a routing rule resolves a request to a backend of the other dialect it is refused with 422 and no record is written. Perdurance does not translate between dialects: a translation layer owns a mapping it has to keep current with two vendors, and every field it fails to understand is a corrupted upstream call. Reaching an Anthropic model from an OpenAI client is what an openai_compat or openrouter backend is for — see Routing.

202 from POST /requests

{ "request_id": "01J8F2ZK9QX3M4NBVWT7", "status": "pending" }

Re-sending an identical body

Inside the idempotency window — ten minutes — a byte-identical body hashes onto the record already there. Idempotency and resume covers what counts as identical.

Record's state when the hash hitsNot streamingStreaming
runningHolds until it settles, then returns the stored answerReplays the stored chunks, then live-tails to completion
succeeded, failedReturns what was storedReplays the stored chunks

The provider is called once across all of it.

Retrieval

GET /requests/{id}

Without Accept: text/event-stream, one JSON object:

{
  "request_id": "01J8F2ZK9QX3M4NBVWT7",
  "status": "succeeded",
  "dialect": "openai",
  "streaming": false,
  "attempts": 1,
  "recoveries": 0,
  "next_retry_at": null,
  "response": {}
}

response carries the stored answer, embedded exactly as it was stored rather than reserialised — a response whose keys moved is no longer the provider's own bytes. A failed request carries error instead. A request that has not finished carries neither, which is what makes "no answer yet" a fact about the shape rather than something you parse for.

A streamed answer lives in its chunks and appears under neither field. Read it through the replay below.

attempts, recoveries and next_retry_at are the execution history: how many times the request was taken up, how many of those followed a worker dying mid-flight, and when the next attempt is due.

GET /requests/{id} with Accept: text/event-stream

The same URL replays the answer as SSE. Each event carries its sequence number as its id.

Request stateBehaviour
pendingWaits for the first chunk, or returns the terminal state if execution fails first
runningReplays the stored chunks, then live-tails
succeeded, failedReplays the stored chunks, then ends

Chunks are replayed as the provider framed them, event names included, with id: <seq> written in front. Strip the id lines and you have exactly what the provider sent.

A request that failed ends its replay with an event: failed carrying the stored error. It has no id, because a failure is not a place to resume from.

Resuming

Last-Event-ID: 25

The reply starts at sequence 26 and otherwise behaves exactly as above, live tail included. A Last-Event-ID past the end simply waits for what has not arrived yet.

GET /requests

A listing, newest first, for finding an id you did not keep.

ParameterMeaning
limitHow many to return
afterAn opaque cursor from a previous page — hand back what you were given

Each row carries the request's status, model, dialect, key and backend, its token counts, how many bytes it holds, and its created, started and finished timestamps. The stored body and answer are deliberately absent: they are the largest columns in the table and a page of them would be megabytes. GET /requests/{id} is where a body is read, one at a time.

On this page