*/ public static function categories(): array { return [self::INVALID_CREDENTIALS, self::PERMISSION, self::RATE_LIMITED, self::QUOTA, self::SERVER_ERROR, self::TIMEOUT, self::STREAM_INTERRUPTED, self::REQUEST_REFUSED, self::NO_ANSWER, self::INTERNAL, self::ENCRYPTION_KEY, self::JOB_FAILED, self::KNOWLEDGE_FAILED, self::CRON_DELAYED, self::MIGRATION_FAILED, self::UNCERTAIN_USAGE, self::PENDING_HOLD]; } /** @return list the billing states a live provider failure can have */ public static function billingStates(): array { return [self::BILLING_NOT_SENT, self::BILLING_NOT_EXPECTED, self::BILLING_UNCERTAIN, self::BILLING_REPORTED]; } /** The category a provider failure belongs to, from what was observed (the kind and, when there was one, the HTTP status). */ public static function categoryForProvider(string $kind, ?int $http): string { return match ($kind) { ProviderException::AUTH => $http === 401 ? self::INVALID_CREDENTIALS : self::PERMISSION, ProviderException::QUOTA_EXHAUSTED => self::QUOTA, ProviderException::RATE_LIMITED => self::RATE_LIMITED, ProviderException::TIMEOUT => self::TIMEOUT, // The adapters raise this kind when the visitor's connection was closed after the request had been sent: the stream stopped, which // is what the category says. It must not read as an Aiwend fault. 'cancelled' => self::STREAM_INTERRUPTED, ProviderException::INVALID_REQUEST => self::REQUEST_REFUSED, ProviderException::UNAVAILABLE => $http !== null && $http >= 500 ? self::SERVER_ERROR : self::STREAM_INTERRUPTED, ProviderException::CONTENT_FILTERED, ProviderException::OUTPUT_LIMIT, ProviderException::EMPTY_RESPONSE => self::NO_ANSWER, default => self::INTERNAL, }; } /** * Billing, from the evidence only. Never sent: nothing was charged. Usage reported: it was charged. A definitive pre-processing * refusal (the transport's own `rejected` flag, which depends on the provider's documented list, never on the number alone): the * ledger settles zero and a charge is not expected, which no live account has confirmed. Everything else reached the provider and * ended unknown. */ public static function billingFor(bool $dispatched, bool $rejected, bool $usageKnown): string { return match (true) { !$dispatched => self::BILLING_NOT_SENT, $usageKnown => self::BILLING_REPORTED, $rejected => self::BILLING_NOT_EXPECTED, default => self::BILLING_UNCERTAIN, }; } /** * @return array{category:string, title:string, known:string, possible:string, temporary:string, temporary_text:string, billing:string, billing_text:string, auto:string, action:string} */ public static function describe(string $category, string $billing = self::BILLING_NOT_APPLICABLE): array { $r = self::records()[$category] ?? self::generic(); $billing = in_array($billing, [self::BILLING_NOT_SENT, self::BILLING_NOT_EXPECTED, self::BILLING_UNCERTAIN, self::BILLING_REPORTED, self::BILLING_NOT_APPLICABLE], true) ? $billing : self::BILLING_NOT_APPLICABLE; return [ 'category' => isset(self::records()[$category]) ? $category : 'unknown', 'title' => $r['title'], 'known' => $r['known'], 'possible' => $r['possible'], 'temporary' => $r['temporary'], 'temporary_text' => match ($r['temporary']) { 'yes' => __('Probably temporary.', 'aiwend'), 'no' => __('Not temporary: it stays until something is changed.', 'aiwend'), default => __('Unknown whether it is temporary.', 'aiwend'), }, 'billing' => $billing, 'billing_text' => self::billingText($billing), 'auto' => $r['auto'], 'action' => $r['action'], ]; } private static function billingText(string $billing): string { return match ($billing) { self::BILLING_NOT_SENT => __('Nothing was sent to the provider, so no usage was charged.', 'aiwend'), self::BILLING_NOT_EXPECTED => __('The provider refused the request before processing it. Aiwend recorded zero usage because the provider documents that such a refusal is not processed; this has not been confirmed on a live account.', 'aiwend'), self::BILLING_UNCERTAIN => __('The request reached the provider and its outcome is unknown, so it may have been charged. Aiwend keeps the estimated amount held against your limit until you resolve it.', 'aiwend'), self::BILLING_REPORTED => __('The provider reported usage for this request, so it was charged.', 'aiwend'), default => __('This is not about provider billing.', 'aiwend'), }; } /** @return array{title:string, known:string, possible:string, temporary:string, auto:string, action:string} */ private static function generic(): array { return ['title' => __('Something went wrong', 'aiwend'), 'known' => __('A failure was recorded, but Aiwend does not recognise its category.', 'aiwend'), 'possible' => __('The cause is not known.', 'aiwend'), 'temporary' => 'unknown', 'auto' => __('Nothing is retried automatically.', 'aiwend'), 'action' => __('Check the activity log on the Diagnostics page and your PHP error log.', 'aiwend')]; } /** @return array */ private static function records(): array { $noRetry = __('Nothing. Aiwend does not retry this request and does not change the saved connection.', 'aiwend'); $heldNoRetry = __('Aiwend does not resend this request and does not release its held amount. After repeated unknown outcomes it pauses the connection for a few minutes.', 'aiwend'); return [ self::INVALID_CREDENTIALS => ['title' => __('The provider did not accept the key', 'aiwend'), 'known' => __('The provider answered that it does not accept the key (HTTP 401).', 'aiwend'), 'possible' => __('The key may have been revoked or have expired, been mistyped, or belong to a different provider.', 'aiwend'), 'temporary' => 'no', 'auto' => $noRetry, 'action' => __('Replace the key on the AI connections screen, then use Check access.', 'aiwend')], self::PERMISSION => ['title' => __('The provider refused because of permissions or the account', 'aiwend'), 'known' => __('The provider refused the request because of access rights or the state of the account (for example HTTP 403 or 402).', 'aiwend'), 'possible' => __('The key may not be allowed to use this model or project, the account may be restricted or unpaid, or the provider may not serve your region.', 'aiwend'), 'temporary' => 'unknown', 'auto' => $noRetry, 'action' => __('Check the project, permissions and billing in your provider account, then use Check access.', 'aiwend')], self::RATE_LIMITED => ['title' => __('The provider said "too many requests"', 'aiwend'), 'known' => __('The provider answered with a rate-limit status (HTTP 429). It did not say why.', 'aiwend'), 'possible' => __('It may be a momentary limit, or a limit of your plan, or an exhausted quota; the provider did not say which.', 'aiwend'), 'temporary' => 'unknown', 'auto' => __('Nothing. Visitors can send the message again; Aiwend does not retry it for them.', 'aiwend'), 'action' => __('Wait a few minutes. If it keeps happening, check the plan and quota in your provider account.', 'aiwend')], self::QUOTA => ['title' => __('The provider account reached a spend or usage limit', 'aiwend'), 'known' => __('The provider said the account reached a spend or usage limit.', 'aiwend'), 'possible' => __('A monthly cap, set by you or by the provider, may have been reached.', 'aiwend'), 'temporary' => 'no', 'auto' => $noRetry, 'action' => __('Raise the limit or add credit in your provider account. Waiting a few seconds does not help.', 'aiwend')], self::SERVER_ERROR => ['title' => __('The provider reported a server error', 'aiwend'), 'known' => __('The provider answered with a server error (HTTP 5xx) or was overloaded.', 'aiwend'), 'possible' => __('The provider may have an outage or be overloaded. OpenRouter also answers 503 when no provider meets the routing requirements, which includes Aiwend’s rule of only using providers that do not store prompts.', 'aiwend'), 'temporary' => 'yes', 'auto' => $heldNoRetry, 'action' => __('Wait and try again later. When you know from your provider’s usage page whether the request was charged, resolve it on the Usage page.', 'aiwend')], self::TIMEOUT => ['title' => __('The provider did not answer in time', 'aiwend'), 'known' => __('No answer arrived within the time allowed.', 'aiwend'), 'possible' => __('The provider may be slow, or the network or a firewall between your server and the provider may be interfering.', 'aiwend'), 'temporary' => 'yes', 'auto' => $heldNoRetry, 'action' => __('Try again later. If it keeps happening, ask your host whether outgoing connections to the provider are allowed.', 'aiwend')], self::STREAM_INTERRUPTED => ['title' => __('The connection to the provider broke', 'aiwend'), 'known' => __('The connection ended, or the answer stream stopped or was malformed, before a complete answer arrived.', 'aiwend'), 'possible' => __('A network problem, a provider restart, or a proxy that cuts long connections may be the cause.', 'aiwend'), 'temporary' => 'yes', 'auto' => $heldNoRetry, 'action' => __('Try again later. Resolve the held request on the Usage page once you know whether it was charged.', 'aiwend')], self::REQUEST_REFUSED => ['title' => __('The provider refused the request as invalid', 'aiwend'), 'known' => __('The provider answered that the request cannot be processed (for example HTTP 400, 404 or 413).', 'aiwend'), 'possible' => __('The model may not be available to this key or may have been retired, or the request may be too large. On OpenRouter it can also mean that no provider of that model accepts Aiwend’s privacy rule (prompts are only sent to providers that do not store or train on them); the provider’s reply does not say which.', 'aiwend'), 'temporary' => 'no', 'auto' => $noRetry, 'action' => __('Choose an available model on the assistant’s AI & model tab and use Check access.', 'aiwend')], self::NO_ANSWER => ['title' => __('The provider finished without a usable answer', 'aiwend'), 'known' => __('The provider ended the response without answer text.', 'aiwend'), 'possible' => __('A safety filter may have stopped it, the answer-length limit may have been reached, or the reply was empty.', 'aiwend'), 'temporary' => 'unknown', 'auto' => __('Nothing. This is not an outage.', 'aiwend'), 'action' => __('If it happens often, review the assistant’s instruction and its answer length: a model that thinks before answering can use the whole limit before writing any text (the usage is still billed). Raise the limit on the assistant’s Answer length setting, which raises the highest possible cost of one answer, or choose a model that does not think. Aiwend never raises it for you and never retries.', 'aiwend')], self::INTERNAL => ['title' => __('Aiwend failed after sending the request', 'aiwend'), 'known' => __('Aiwend itself stopped after the request had been sent to the provider.', 'aiwend'), 'possible' => __('A plugin conflict, a PHP memory or time limit, or a bug may be the cause.', 'aiwend'), 'temporary' => 'unknown', 'auto' => $heldNoRetry, 'action' => __('Check your PHP error log, then resolve the held request on the Usage page.', 'aiwend')], self::ENCRYPTION_KEY => ['title' => __('The site encryption key is missing', 'aiwend'), 'known' => __('Aiwend cannot read its encryption key, so saved provider keys cannot be used and no request is sent.', 'aiwend'), 'possible' => __('The key file may have been moved, deleted or made unreadable, or the site may have been restored without it.', 'aiwend'), 'temporary' => 'no', 'auto' => __('Nothing. Assistants cannot use an AI provider until the key is restored.', 'aiwend'), 'action' => __('Restore the key file or create a new one on the setup screen, then enter the provider keys again.', 'aiwend')], self::JOB_FAILED => ['title' => __('A background task failed', 'aiwend'), 'known' => __('A background task failed several times and was stopped.', 'aiwend'), 'possible' => __('The error text shows the reason; common causes are a missing item or a server limit.', 'aiwend'), 'temporary' => 'unknown', 'auto' => __('Aiwend already retried it with growing delays until its attempt limit.', 'aiwend'), 'action' => __('For the kinds of task that are safe to repeat, use Retry. Otherwise follow the error text.', 'aiwend')], self::KNOWLEDGE_FAILED => ['title' => __('A knowledge source could not be processed', 'aiwend'), 'known' => __('A source could not be read or indexed, so assistants cannot use it yet.', 'aiwend'), 'possible' => __('The file may be unreadable or a scanned document without text, or the content may have changed while it was processed.', 'aiwend'), 'temporary' => 'unknown', 'auto' => __('A daily check queues again any source whose indexing job was lost.', 'aiwend'), 'action' => __('Open the source, fix the file or content, and re-index it.', 'aiwend')], self::CRON_DELAYED => ['title' => __('Background jobs are late or not scheduled', 'aiwend'), 'known' => __('Background jobs did not run recently, or their schedule is missing or duplicated.', 'aiwend'), 'possible' => __('A quiet site that relies on visits to trigger WordPress cron, a disabled cron, or a host that blocks the site from calling itself may be the cause.', 'aiwend'), 'temporary' => 'unknown', 'auto' => __('Jobs run whenever WordPress cron runs.', 'aiwend'), 'action' => __('Visit the site, or ask your host to run WordPress cron on a real schedule.', 'aiwend')], self::MIGRATION_FAILED => ['title' => __('The database update did not finish', 'aiwend'), 'known' => __('Aiwend could not finish updating its database tables.', 'aiwend'), 'possible' => __('The database user may lack permission to alter tables, another request may hold the update, or a time limit may have been reached.', 'aiwend'), 'temporary' => 'unknown', 'auto' => __('Aiwend tries again about once a minute; every step can safely be repeated.', 'aiwend'), 'action' => __('Use "Try again now". If it stays, ask your host to check the PHP error log and the database permissions.', 'aiwend')], self::UNCERTAIN_USAGE => ['title' => __('Some usage is uncertain', 'aiwend'), 'known' => __('A request was sent to the provider but Aiwend never learned its outcome.', 'aiwend'), 'possible' => __('The provider may have processed and billed it, or may not have.', 'aiwend'), 'temporary' => 'no', 'auto' => __('Aiwend does not retry the request and does not release the held amount: the estimate stays held against your limit.', 'aiwend'), 'action' => __('Check your provider’s usage page for that time, then enter the usage or charge the held amount on the Usage page.', 'aiwend')], self::PENDING_HOLD => ['title' => __('Some usage reservations have remained pending longer than expected', 'aiwend'), 'known' => __('According to Aiwend’s own records, some requests reserved usage capacity and have been neither finished nor released for much longer than a request normally takes. Aiwend does not know whether the provider processed them.', 'aiwend'), 'possible' => __('A PHP process may have stopped between reserving and finishing (a fatal error, a host time limit or a restart), or a request may still be running. These reservations may reduce available usage capacity. Provider-billed usage may differ from the reserved units, which are Aiwend’s own measure.', 'aiwend'), 'temporary' => 'unknown', 'auto' => __('Nothing. Aiwend does not release, settle, charge or expire these reservations, and it does not contact the provider about them.', 'aiwend'), 'action' => __('Check your provider’s usage page for the time shown. If the capacity still available looks too low for new requests, raise the site limit on the Usage page. This version cannot resolve these reservations from the Usage page; they are listed so you can see them.', 'aiwend')], ]; } }