| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\AI\Providers; |
| 4 |
|
| 5 |
use WPDeveloper\BetterDocs\AI\ModelRegistry; |
| 6 |
use WPDeveloper\BetterDocs\AI\Contracts\AIProvider; |
| 7 |
use WPDeveloper\BetterDocs\Utils\AIHelper; |
| 8 |
|
| 9 |
/** |
| 10 |
* Shared plumbing for concrete providers: construction, model lookup, the |
| 11 |
* min-token floor, HTTP transport, and usage normalization. Concrete providers |
| 12 |
* implement id()/label()/chat()/validate_key() and reuse the helpers here. |
| 13 |
* |
| 14 |
* @since 4.4.0 |
| 15 |
*/ |
| 16 |
abstract class BaseProvider implements AIProvider { |
| 17 |
|
| 18 |
/** |
| 19 |
* @var string Configured API key. |
| 20 |
*/ |
| 21 |
protected $api_key; |
| 22 |
|
| 23 |
/** |
| 24 |
* @var string Configured model id. |
| 25 |
*/ |
| 26 |
protected $model; |
| 27 |
|
| 28 |
/** |
| 29 |
* @param string $api_key Configured key for this platform. |
| 30 |
* @param string $model Configured model id; falls back to the platform default. |
| 31 |
*/ |
| 32 |
public function __construct( $api_key = '', $model = '' ) { |
| 33 |
$this->api_key = (string) $api_key; |
| 34 |
$this->model = $model !== '' ? (string) $model : $this->default_model(); |
| 35 |
} |
| 36 |
|
| 37 |
/** |
| 38 |
* {@inheritDoc} |
| 39 |
*/ |
| 40 |
public function models() { |
| 41 |
return ModelRegistry::models( $this->id() ); |
| 42 |
} |
| 43 |
|
| 44 |
/** |
| 45 |
* {@inheritDoc} |
| 46 |
*/ |
| 47 |
public function default_model() { |
| 48 |
return ModelRegistry::default_model( $this->id() ); |
| 49 |
} |
| 50 |
|
| 51 |
/** |
| 52 |
* Resolve the model to use for a request (option override wins). |
| 53 |
* |
| 54 |
* @param array $options |
| 55 |
* @return string |
| 56 |
*/ |
| 57 |
protected function resolve_model( $options ) { |
| 58 |
return ! empty( $options['model'] ) ? (string) $options['model'] : $this->model; |
| 59 |
} |
| 60 |
|
| 61 |
/** |
| 62 |
* Apply the per-feature min-token floor when a context is supplied. |
| 63 |
* |
| 64 |
* Reuses the existing policy in AIHelper so the floor stays consistent with |
| 65 |
* the React notice and server-side save validation. |
| 66 |
* |
| 67 |
* @param int $max_tokens |
| 68 |
* @param string $model |
| 69 |
* @param string|null $context |
| 70 |
* @return int |
| 71 |
*/ |
| 72 |
protected function floor_tokens( $max_tokens, $model, $context = null ) { |
| 73 |
$max_tokens = (int) $max_tokens; |
| 74 |
if ( null === $context ) { |
| 75 |
return $max_tokens; |
| 76 |
} |
| 77 |
$min = AIHelper::get_min_tokens( $context, $model ); |
| 78 |
return ( $min > 0 && $max_tokens < $min ) ? $min : $max_tokens; |
| 79 |
} |
| 80 |
|
| 81 |
/** |
| 82 |
* POST JSON and decode the response into an array (or WP_Error). |
| 83 |
* |
| 84 |
* @param string $url |
| 85 |
* @param array $headers |
| 86 |
* @param array $body |
| 87 |
* @param int $timeout |
| 88 |
* @param int|null $status_code Out-param: set to the HTTP response code (0 on |
| 89 |
* transport failure) so callers can classify errors |
| 90 |
* by status without re-reading the response. |
| 91 |
* @return array|\WP_Error Decoded body array, or WP_Error on transport failure. |
| 92 |
*/ |
| 93 |
protected function post_json( $url, $headers, $body, $timeout = 50, &$status_code = null ) { |
| 94 |
$headers = wp_parse_args( $headers, array( 'Content-Type' => 'application/json' ) ); |
| 95 |
|
| 96 |
$response = wp_remote_post( $url, array( |
| 97 |
'headers' => $headers, |
| 98 |
'body' => wp_json_encode( $body ), |
| 99 |
'timeout' => (int) $timeout, |
| 100 |
) ); |
| 101 |
|
| 102 |
if ( is_wp_error( $response ) ) { |
| 103 |
$status_code = 0; |
| 104 |
return new \WP_Error( 'api_error', sprintf( |
| 105 |
/* translators: 1: provider label, 2: error message */ |
| 106 |
__( 'Failed to connect to %1$s: %2$s', 'betterdocs' ), |
| 107 |
$this->label(), |
| 108 |
$response->get_error_message() |
| 109 |
) ); |
| 110 |
} |
| 111 |
|
| 112 |
$status_code = (int) wp_remote_retrieve_response_code( $response ); |
| 113 |
|
| 114 |
$data = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 115 |
if ( ! is_array( $data ) ) { |
| 116 |
return new \WP_Error( 'no_content', sprintf( |
| 117 |
/* translators: %s: provider label */ |
| 118 |
__( 'Empty or invalid response from %s.', 'betterdocs' ), |
| 119 |
$this->label() |
| 120 |
) ); |
| 121 |
} |
| 122 |
|
| 123 |
return $data; |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Turn an HTTP status + the provider's raw error text into a clear, actionable |
| 128 |
* message. Chiefly distinguishes a retired/unknown model (404) from a genuine |
| 129 |
* quota / rate-limit rejection (429) — without this, a dead model id and a real |
| 130 |
* quota error both surface the provider's raw text and look identical (a retired |
| 131 |
* model reads like "quota exceeded"). Falls back to the raw message otherwise. |
| 132 |
* |
| 133 |
* @param int $status HTTP status code. |
| 134 |
* @param string $raw_message Provider-supplied error message. |
| 135 |
* @param string $model Model id in play, for the "unavailable" message. |
| 136 |
* @return string |
| 137 |
*/ |
| 138 |
protected function classify_http_error( $status, $raw_message, $model = '' ) { |
| 139 |
$status = (int) $status; |
| 140 |
$raw = strtolower( (string) $raw_message ); |
| 141 |
|
| 142 |
if ( 404 === $status || false !== strpos( $raw, 'not found' ) || false !== strpos( $raw, 'is not supported' ) ) { |
| 143 |
return sprintf( |
| 144 |
/* translators: 1: provider label, 2: model id */ |
| 145 |
__( 'The %1$s model "%2$s" is unavailable — it may have been retired. Choose a different model.', 'betterdocs' ), |
| 146 |
$this->label(), |
| 147 |
$model |
| 148 |
); |
| 149 |
} |
| 150 |
|
| 151 |
if ( 429 === $status || false !== strpos( $raw, 'resource_exhausted' ) || false !== strpos( $raw, 'quota' ) || false !== strpos( $raw, 'rate limit' ) ) { |
| 152 |
return sprintf( |
| 153 |
/* translators: %s: provider label */ |
| 154 |
__( 'Your %s request hit a quota or rate limit. Check your plan and limits, then try again.', 'betterdocs' ), |
| 155 |
$this->label() |
| 156 |
); |
| 157 |
} |
| 158 |
|
| 159 |
return (string) $raw_message; |
| 160 |
} |
| 161 |
|
| 162 |
/** |
| 163 |
* Normalize a usage block into prompt/completion/total token counts. |
| 164 |
* |
| 165 |
* @param array $usage Provider-specific usage payload. |
| 166 |
* @param array $map Keys map: array( 'prompt'=>..., 'completion'=>..., 'total'=>... ). |
| 167 |
* @return array |
| 168 |
*/ |
| 169 |
protected function normalize_usage( $usage, $map ) { |
| 170 |
$get = function ( $key ) use ( $usage ) { |
| 171 |
return ( $key && isset( $usage[ $key ] ) ) ? (int) $usage[ $key ] : null; |
| 172 |
}; |
| 173 |
|
| 174 |
$prompt = $get( isset( $map['prompt'] ) ? $map['prompt'] : null ); |
| 175 |
$completion = $get( isset( $map['completion'] ) ? $map['completion'] : null ); |
| 176 |
$total = $get( isset( $map['total'] ) ? $map['total'] : null ); |
| 177 |
|
| 178 |
if ( null === $total && ( null !== $prompt || null !== $completion ) ) { |
| 179 |
$total = (int) $prompt + (int) $completion; |
| 180 |
} |
| 181 |
|
| 182 |
return array( |
| 183 |
'prompt_tokens' => $prompt, |
| 184 |
'completion_tokens' => $completion, |
| 185 |
'total_tokens' => $total, |
| 186 |
); |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* Build the normalized success envelope returned by chat(). |
| 191 |
* |
| 192 |
* @param string $content |
| 193 |
* @param string $model |
| 194 |
* @param array $usage |
| 195 |
* @param string|null $finish_reason |
| 196 |
* @return array |
| 197 |
*/ |
| 198 |
protected function success( $content, $model, $usage, $finish_reason = null ) { |
| 199 |
return array( |
| 200 |
'success' => true, |
| 201 |
'content' => (string) $content, |
| 202 |
'model' => (string) $model, |
| 203 |
'usage' => $usage, |
| 204 |
'finish_reason' => $finish_reason, |
| 205 |
); |
| 206 |
} |
| 207 |
} |
| 208 |
|