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 terminalrequest.failed event — is shaped as AiPlatformProblem, an RFC 9457-style application/problem+json body (AI_PLATFORM_ERROR_MEDIA_TYPE):
- The initial HTTP response is not
ok.streamChat()throwsAiPlatformHttpErrorbefore yielding anything. - The run fails after the stream has already started. You get a normal
request.failedevent carrying aproblemfield — 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:
- 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.acceptedwithreplayed: 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 therequest.failedevent’sproblem.retryable(in-stream path) first, and honorretryAfterMswhen 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 anAbortSignal to cancel:
- Calling
controller.abort()stops your local read of the stream — the pendingfetchis aborted, the reader rejects, and the exception propagates out of yourfor awaitloop (see “What else can throw,” above). It does not yield a tidyrequest.cancelledevent; 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.completedyou already received, or any durable side effect the server started — stays committed. This package has no reconciliation call today: the design spec’s task-modeGET /v1/runs/:idstatus read andPOST /v1/runs/:id/cancelare planned, not implemented here (same caveat as the reconnect section instreaming.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 withinv1 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.