Crux
GuidesObservability

Adapter outcomes

Handle a rate limit, timeout, or refusal the same way on every provider.

A rate limit from OpenAI, a stream timeout from Anthropic, a safety refusal from Google: without normalization, each one needs provider-specific error handling. Crux's first-party adapters (@use-crux/openai, @use-crux/anthropic, @use-crux/google, and @use-crux/ai) map every completed call and every failure into one closed taxonomy, so one code path handles them all:

import { isCruxAdapterError } from "@use-crux/core/adapter";

try {
  const result = await adapter.generate(supportReply, { model, input });
  return result.text;
} catch (error) {
  if (isCruxAdapterError(error)) {
    if (error.providerError.retryable) {
      // rate limit, timeout, transient provider failure
      return requeue(job, error.providerError.code);
    }
    // invalid request, refusal, safety block: retrying will not help
    return fail(job, error.providerError.code);
  }
  throw error;
}

Failures throw CruxAdapterError, which carries a normalized providerError and keeps the original throw as cause. retryable is classification only: adapters do not run a second network retry loop, and SDK clients keep their own maxRetries.

import type {
  CruxFinishReason,
  CruxProviderError,
  CruxAdapterError,
} from "@use-crux/core/adapter";

Finish reasons

AdapterResponse.finishReason is CruxFinishReason | undefined:

ValueMeaning
stopNatural completion
lengthHit a max-token / length limit
tool-callsModel requested one or more completed tool calls
content-filterProvider safety / content filter blocked output (or blocked the prompt)
refusalModel refused (when the provider distinguishes refusal from filter)
errorProvider reported an error finish
abortedCooperative abort
unknownUnrecognized provider string clamped honestly

Raw provider strings (Anthropic end_turn, Google SAFETY, AI SDK other, ...) never leak through as the public finish reason.

Error classification

interface CruxProviderError {
  readonly kind: CruxProviderErrorKind;
  /** Bounded, namespaced machine id, e.g. `openai.rate_limit`. */
  readonly code: string;
  readonly retryable: boolean;
  /** Optional redacted human text, never a raw provider payload dump. */
  readonly message?: string;
}

type CruxProviderErrorKind =
  | "refusal"
  | "safety"
  | "content-filter"
  | "rate-limit"
  | "timeout"
  | "aborted"
  | "invalid-request"
  | "provider-error"
  | "unknown";

Caller AbortSignal and Crux budget timeouts are classified as aborted and timeout, and usage is honestly omitted when the provider omits counts (never invented zeros). Optional message is routed through the shared observability redaction path (toSafeJsonValue) before retention: sensitive keys are stripped and long strings are truncated. See Privacy.

Tool calls are always completed

Tool calls appear only after they are fully assembled (generate completion or stream completion): no adapter exposes progressive tool-call argument deltas, on any provider. Streams still yield text deltas. The rationale for this non-goal, the full normalization dimension table, and per-adapter mapping notes live in the Adapter interface reference.

On this page