lastError; } /** * Причина последней ошибки, безопасная для отправки в браузер: ключ API * (если он каким-то образом попал в текст) заменён на `***`. */ public function maskedLastError(): ?string { return self::maskKey( $this->lastError, $this->apiKey ); } /** * У OpenAI-совместимых шлюзов нет единого машинного кода ошибки. */ public function lastErrorCode(): ?string { return null; } /** * Вырезает ключ из текста. * * @param string|null $message Текст. * @param string $key Ключ API. */ public static function maskKey( ?string $message, string $key ): ?string { if ( null === $message || '' === $key ) { return $message; } return str_replace( $key, '***', $message ); } /** * {@inheritDoc} */ public function supports( string $sourceLocale, string $targetLocale ): bool { unset( $sourceLocale, $targetLocale ); return '' !== $this->apiKey && '' !== $this->model; } /** * {@inheritDoc} */ public function translateBatch( array $items, string $sourceLocale, string $targetLocale, TranslationContext $context ): array { $this->lastError = null; if ( array() === $items || '' === $this->apiKey ) { return array(); } $payload = OpenAiRequestBuilder::buildPayload( $items, $this->model, $sourceLocale, $targetLocale, $context, $context->targetLanguageLabel ); $body = $this->send( $payload, array_keys( $items ) ); if ( null === $body ) { // $this->lastError уже заполнен внутри send(). return array(); } $result = OpenAiRequestBuilder::parseResponse( $body, array_keys( $items ) ); if ( array() === $result ) { // HTTP 200, но модель не вернула то, что было запрошено: либо // ответила текстом вместо JSON, либо не тем набором ключей. $this->lastError = 'the model replied, but the reply could not be parsed'; $this->log( $this->lastError . ': ' . substr( $body, 0, 500 ) ); } return $result; } /** * Отправляет запрос с одним повтором при сетевой ошибке или 5xx. * * @param array $payload Тело запроса. * @param list $itemKeys Ключи строк — только для лога ошибки. */ private function send( array $payload, array $itemKeys ): ?string { // Идемпотентность на уровне шлюза: повторный клик с тем же набором // строк не должен тарифицироваться дважды, если шлюз это поддерживает. $idempotencyKey = Hash::ofParts( array_merge( array( $this->model ), $itemKeys ) ); /* * До MAX_ATTEMPTS попыток по TIMEOUT_SECONDS каждая плюс паузы между * ними в сумме способны превысить обычный max_execution_time * хостинга (нередко 30 секунд по умолчанию на дешёвом тарифе) — тогда * PHP оборвал бы запрос сам, отдав вместо ясного сообщения об ошибке * голый HTTP 500. Лимит увеличивается с запасом только на время этого * вызова, не глобально; на хостингах, где set_time_limit() отключён * (safe-режимы, некоторые дешёвые тарифы), функции нет в * function_exists() — вызов просто пропускается, риска фатальной * ошибки это не добавляет. */ if ( function_exists( 'set_time_limit' ) ) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- долгий запрос к OpenAI, запускается явным действием администратора. set_time_limit( ( self::TIMEOUT_SECONDS + self::RETRY_DELAY_SECONDS ) * self::MAX_ATTEMPTS + 10 ); } $attempt = 0; do { ++$attempt; if ( $attempt > 1 ) { sleep( self::RETRY_DELAY_SECONDS ); } $response = wp_remote_post( $this->baseUrl . '/chat/completions', array( 'timeout' => self::TIMEOUT_SECONDS, 'headers' => array( 'Authorization' => 'Bearer ' . $this->apiKey, 'Content-Type' => 'application/json', 'Idempotency-Key' => $idempotencyKey, ), 'body' => wp_json_encode( $payload ), ) ); if ( is_wp_error( $response ) ) { $this->lastError = 'network error: ' . $response->get_error_message(); $this->log( $this->lastError ); continue; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 === $code ) { return (string) wp_remote_retrieve_body( $response ); } $message = OpenAiRequestBuilder::errorMessage( (string) wp_remote_retrieve_body( $response ) ); $this->lastError = sprintf( 'HTTP %d%s', $code, null !== $message ? ': ' . $message : '' ); $this->log( $this->lastError ); // 4xx — ошибка запроса (неверная модель, лимит и т.п.), повтор не поможет. if ( $code < 500 ) { return null; } } while ( $attempt < self::MAX_ATTEMPTS ); return null; } /** * Пишет ошибку в debug.log. Ключ в сообщение никогда не попадает. * * @param string $message Текст ошибки. */ private function log( string $message ): void { if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) { return; } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( '[lingocanvas] OpenAI: ' . $message ); } }