airouter/openai-compatible-errors is a PHP 8.1+ library for the failure boundary around OpenAI-compatible HTTP APIs. It turns varied gateway, SDK and JSON error shapes into a small immutable object; parses Retry-After; makes replay safety explicit before retrying; redacts bounded diagnostics; and incrementally inspects Chat Completions or Responses Server-Sent Events (SSE).
It has no runtime dependencies. It does not send requests, sleep, retry automatically, buffer an entire stream, or retain raw provider payloads. The application owns transport, cancellation, idempotency, billing and replay.
The AI-ROUTER API gateway is the service context for the compatible-endpoint examples. This package remains transport-neutral and can be used with any gateway or provider that follows the same API shape.
The design is grounded in the OpenAI error-code guide, the MDN Retry-After reference, and the WHATWG Server-Sent Events specification. For a worked replay-safety model, read the LLM stream retry-safety walkthrough. The repository also includes a PHP failure-boundary article with the decision rationale and bounded examples.
Install this implementation from Packagist. Teams using Ruby can compare the native RubyGems implementation; the packages share a safety boundary but do not share runtime code.
composer require airouter/openai-compatible-errors
The package uses PSR-4 autoloading under AiRouter\OpenAICompatibleErrors and works beside Guzzle, Symfony HttpClient, Laravel HTTP, PSR-18 clients or a native cURL integration without selecting one of them as a dependency.
Pass a response-like array or an SDK exception. Provider-controlled message text is omitted by default:
use AiRouter\OpenAICompatibleErrors\OpenAICompatibleErrors;
$error = OpenAICompatibleErrors::normalizeError(
status: 429,
headers: [
'retry-after' => '2',
'x-request-id' => 'req_php_01',
],
body: [
'error' => [
'type' => 'requests',
'code' => 'rate_limit_exceeded',
'message' => 'provider detail',
],
],
);
$error->category->value; // rate_limit
$error->status; // 429
$error->retryAfterMs; // 2000
$error->requestId; // req_php_01
$error->providerMessage; // null
$logger->warning('AI API failure', $error->toLogArray());
ApiError exposes stable library-owned message text and validated short identifiers. It has no raw body, headers, exception cause, traceback, prompt or generated-output field. If an operator genuinely needs provider text, opt in explicitly; common bearer and API-key formats are still redacted and bounded:
$diagnostic = OpenAICompatibleErrors::normalizeError(
$exception,
includeProviderMessage: true,
);
$logger->warning(
'AI API failure',
$diagnostic->toLogArray(includeProviderMessage: true),
);
The opt-in reduces risk; it is not permission to log arbitrary customer data.
The normalizer reads public status/statusCode and headers properties, and recognizes getStatusCode and getHeaders when an object exposes those standard methods. It deliberately does not consume a response stream through getBody. Pass the body explicitly at the boundary:
$error = OpenAICompatibleErrors::normalizeError(
$exception,
status: $response->getStatusCode(),
headers: $response->getHeaders(),
body: (string) $response->getBody(),
);
Classification prefers HTTP status, structured error.code/error.type and class names. Free-form exception messages are not retained and are only a last-resort signal for transport categories.
Categories include authentication, permission, rate_limit, quota, conflict, validation, not_found, payload_too_large, timeout, network, upstream, server, schema, endpoint, aborted, stream and unknown. A 409 conflict remains a manual decision because the library cannot infer how the application should resolve state.
An HTTP method does not prove that a request is safe to replay. Supply the operation contract and phase in which the failure happened:
use AiRouter\OpenAICompatibleErrors\Retry\ReplaySafety;
use AiRouter\OpenAICompatibleErrors\Retry\RequestPhase;
use AiRouter\OpenAICompatibleErrors\Retry\RetryContext;
$context = new RetryContext(
method: 'POST',
phase: RequestPhase::HttpError,
replaySafety: ReplaySafety::Safe,
attempt: 1,
elapsedMs: 350,
);
$plan = OpenAICompatibleErrors::decideRetry($error, $context);
if ($plan->retry()) {
$scheduler->after($plan->delayMs ?? 0, fn () => replay_request());
}
The result is deliberately three-state:
- retry only for a transient category, known replay-safe operation, known phase, no observed stream output and remaining budgets;
- do_not_retry for permanent failures, unsafe replay, cancellation, completion, partial output or exhausted budgets;
- manual_decision when evidence is missing, unclassified or invalid.
Server Retry-After and millisecond hints are parsed without network calls. Duplicate hints use the longest valid delay. A malformed present hint becomes a bounded sentinel, so the default policy fails closed instead of replacing a server instruction with a short local retry. Local exponential backoff supports full jitter and an injectable random callable for deterministic tests.
The library never sleeps, opens a socket, calls a provider or replays a request.
SseInspector consumes byte chunks incrementally. It handles CRLF/LF framing, UTF-8 split across network chunks, Chat Completions deltas, Responses event names, [DONE], provider error events and unexpected EOF. It records state only:
use AiRouter\OpenAICompatibleErrors\Sse\SseInspector;
$inspector = new SseInspector();
foreach ($responseBodyChunks as $chunk) {
$inspector->feed($chunk);
render_chunk($chunk);
}
$state = $inspector->close();
if ($state->unexpectedEof() && $state->hasOutput) {
throw new RuntimeException('refuse automatic replay after partial output');
}
Terminal states are done, incomplete, error and unexpected_eof. hasOutput is conservative: a false positive prevents an unsafe replay, while a false negative could duplicate visible output or billing. The inspector never stores generated text or a complete response body.
Use sanitizeForLog for small diagnostic context, not as a data-retention policy:
$safe = OpenAICompatibleErrors::sanitizeForLog([
'provider' => 'example',
'api_key' => getenv('API_KEY'),
'attempt' => 2,
]);
It redacts sensitive key names and common credential formats, limits depth, nodes, keys, items and characters, and avoids traversing exception messages or arbitrary object properties. Keep real credentials, prompts, completions and customer payloads out of fixtures and logs.
This package targets common OpenAI-compatible shapes used by gateways, self-hosted routers and SDK adapters. It is not an OpenAI product, provider certification or promise of complete parity with a vendor's proprietary event schema. Unknown data-bearing SSE events are treated conservatively because replaying after an unrecognized event can duplicate visible output.
Use a full resilience library when you need circuit breaking, cancellation-aware sleep, hedging or request execution. Keep a provider SDK's native exception when one stable provider contract is all your application needs. The value here is an explicit, auditable boundary across multiple OpenAI-compatible endpoints.
- JavaScript and TypeScript package on npm
- Python package on PyPI
- .NET package on NuGet
- JVM contract package on Maven Central
- Rust stream guard on crates.io
composer validate --strict
composer run verify
composer dump-autoload --classmap-authoritative
See CONTRIBUTING.md, SECURITY.md and RELEASING.md for validation, data-boundary and Packagist submission details.
MIT licensed.