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.

A submission may send the same key in a dedicated header instead:

X-Perdurance-Key: sar_ab12cd34_...

The dedicated header wins when both are present. It exists so that Authorization can carry something else, which is what a backend forwarding the caller's own credential needs. See Claude Code and the Agent SDK. A bearer token starting sk-ant- is an Anthropic credential rather than a Perdurance key, and is refused with 401 provider_credential rather than a bare unauthorised.

The administration routes the console drives — namespaces, backends, routing rules, users and keys — are in Administration. Every status the routes on this page 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 with every field, every content block and every array in the order it arrived, except the model id a routing rule rewrites. Object keys are re-serialised in a canonical order, which is what lets an identical retry hash to the same request. 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.

A failed call is answered the same way. The provider's own status, its body byte for byte, and its retry-after are passed through rather than wrapped, and only Perdurance's own failures carry Perdurance's envelope. Errors has the rule.

A stream decides on its first item

A streaming submission does not answer 200 text/event-stream until it has something to send. It waits up to the deployment's synchronous hold, five minutes by default, for the first chunk.

First itemAnswer
A chunk200 text/event-stream, then the provider's own stream
A refusal the provider answered withThe provider's status and body
A failure that was not an HTTP answer502 upstream_failed
Nothing, within the hold504 held_too_long, and the execution carries on

A failure after the first chunk arrives as event: failed, because the status line has already been sent. The provider's own mid-stream errors are chunks, and are forwarded as they are.

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 — the same body hashes onto the record already there. The hash is over the parsed body canonicalised, so object keys may move; numeric spelling and array order may not. 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
succeededReturns what was storedReplays the stored chunks

The provider is called once across all of it.

A failed record is not in that table. A terminal failure releases the idempotency claim, so the same body sent afterwards is a new request, with a second fee and a second provider call. The stored error is read by id, below.

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 failed request carries two more fields beside error, when its failure was an answer the provider gave:

{
  "request_id": "01J8F2ZK9QX3M4NBVWT7",
  "status": "failed",
  "error": {
    "type": "error",
    "error": { "type": "rate_limit_error", "message": "…" }
  },
  "error_status": 429,
  "retry_after": "30"
}

error_status is the provider's own HTTP status and retry_after is its retry-after header, verbatim, so a caller reading a record back knows what the provider said as well as that it said no. Both are absent when the failure was not an HTTP answer, and neither appears in the listing below, which stays a summary.

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. It needs a key that can manage the namespace, where GET /requests/{id} needs only one that can use it.

ParameterMeaning
limitHow many to return: 50 by default, 200 at most. 0 or more than 200 is refused with 400 bad_request rather than clamped
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