Skip to main content
Status: Developer preview — the SDK (@nexislabs/ai-platform) is source-available in the Praxa repo and contract-stable; npm publication follows the public API launch and its security review.

problem+json handling

Every error the platform returns — as the initial HTTP response, or embedded in a terminal request.failed event — is shaped as AiPlatformProblem, an RFC 9457-style application/problem+json body (AI_PLATFORM_ERROR_MEDIA_TYPE):
There are two distinct places a problem shows up, and the SDK treats them differently:
  1. The initial HTTP response is not ok. streamChat() throws AiPlatformHttpError before yielding anything.
  2. The run fails after the stream has already started. You get a normal request.failed event carrying a problem field — the generator ends cleanly (it does not throw) once that event has been yielded.
AiPlatformHttpError has .status: number and .problem: AiPlatformProblem | null. .problem is null when the error response body isn’t valid JSON or doesn’t match AiPlatformProblemSchema — never assume it’s populated. .message falls back through problem?.detail, then problem?.title, then a generic “AI platform request failed” string carrying the HTTP status, so it stays log/display-safe even when .problem is null.

What else can throw

AiPlatformHttpError is only one of several exceptions a streamChat() loop can raise: A catch block that only checks instanceof AiPlatformHttpError mis-handles the other five rows. Check instanceof AiPlatformHttpError first, then instanceof z.ZodError if you want to distinguish a malformed-input or malformed-event bug from everything else, then fall back to an abort/generic-error path.

Error codes

AiPlatformErrorCode is a closed, 15-value enum. The SDK does not hardcode a code-to-retryable table — problem.retryable and problem.retryAfterMs on the actual response are always the authoritative signal. The table below is guidance for what each code generally means, not a contract the SDK enforces:

Idempotency-Key discipline

Per the extraction ADR’s protocol decision: “Idempotency-Key for action-capable requests. Keys are scoped to the authenticated principal, tenant, API version, and normalized request digest. A mismatched replay is a conflict.” Two ways to set the header — streamChat()’s explicit option wins if both are set:
Rules that follow from the ADR:
  • Generate one key per logical action, not per network attempt — once when the person hits “send,” not once per retry.
  • Reuse the exact same key and the exact same request body on retry. The server scopes the key together with a digest of the normalized request; changing anything about the request while reusing the key is a mismatched replay and comes back as conflict, not as a silent success.
  • A clean replay is not an error. If the server recognizes the same key with the same body as one it already ran (or is running), the resulting stream’s first event is request.accepted with replayed: true — handle that as a normal, successful outcome, not a failure path.
  • Only retry after consulting state, per the ADR — never retry blind on any thrown error. Check problem.retryable (HTTP path) or the request.failed event’s problem.retryable (in-stream path) first, and honor retryAfterMs when present.
streamChat() never loops or retries for you — there’s no maxAttempts option and no built-in backoff. The package deliberately contains no automatic retry of streamed POST requests; the loop above is entirely caller-owned, by design. Don’t confuse this with the retry event type documented in streaming.md — that’s the server reporting an internal retry (a provider fallback, for example) it already performed on your behalf, inside a stream that’s still running. It has nothing to do with, and doesn’t require, retrying the streamChat() call itself.

Cancellation

Pass an AbortSignal to cancel:
The rule, verbatim from the extraction ADR: “Cancellation: client abort stops streaming and propagates an abort signal. Durable actions already committed require explicit status/reconciliation; cancellation never implies rollback.” Concretely:
  • Calling controller.abort() stops your local read of the stream — the pending fetch is aborted, the reader rejects, and the exception propagates out of your for await loop (see “What else can throw,” above). It does not yield a tidy request.cancelled event; that event type is for runs the server itself ends as cancelled through some other channel, which is a distinct case from you aborting your own local read.
  • Aborting is not a rollback. Anything the run already committed before your signal took effect — a tool.completed you already received, or any durable side effect the server started — stays committed. This package has no reconciliation call today: the design spec’s task-mode GET /v1/runs/:id status read and POST /v1/runs/:id/cancel are planned, not implemented here (same caveat as the reconnect section in streaming.md). If a turn might have committed something before you cancelled it, your application needs its own way to check — re-reading your own domain state, not this package.
  • Cancellation is local-first: aborting stops your connection; it is a signal, not a guarantee that the run stops executing server-side.

Versioning

Additive event fields are allowed within v1 without notice; event types, required fields, semantics, and error codes are not silently changed underneath you, and a breaking change gets a new API version rather than a mutation of v1. X-AI-Platform-Version: v1 is sent on every request so the server can enforce this from its side too. See typescript.md for the request/response contract and streaming.md for the full event union and approval flow.