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:
| Value | Meaning |
|---|---|
stop | Natural completion |
length | Hit a max-token / length limit |
tool-calls | Model requested one or more completed tool calls |
content-filter | Provider safety / content filter blocked output (or blocked the prompt) |
refusal | Model refused (when the provider distinguishes refusal from filter) |
error | Provider reported an error finish |
aborted | Cooperative abort |
unknown | Unrecognized 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.
Related
- Adapter interface: authoring surface and normalized-outcome contract
- Privacy: what
code/messageretain - Troubleshooting: interpreting finish reasons and failures in Runs