array( 'text_input' => 5, 'image_input' => 8, 'image_output' => 30, ), 'gpt-image-2.5-flare' => array( 'text_input' => 5, 'image_input' => 8, 'image_output' => 30, ), 'gpt-image-2' => array( 'text_input' => 2.5, 'image_input' => 4, 'image_output' => 15, ), ); /** * Seconds to wait for a generation. Must stay below Runner::LEASE_SECONDS. */ public const TIMEOUT = 180; /** * Returns the API key, or '' when none is configured. * * @var callable */ private $api_key; /** * Model name. * * @var string */ private $model; /** * HTTP client. * * @var Http */ private $http; /** * API base URL without a trailing slash. * * @var string */ private $base_url; /** * Prices per model. * * @var array> */ private $pricing; /** * 'high' or 'low' for models that accept input_fidelity, or null. * * @var string|null */ private $input_fidelity; /** * Creates the provider. * * @param callable $api_key Returns the API key, or ''. * @param string $model Model name. * @param Http $http HTTP client. * @param string $base_url API base URL. * @param array>|null $pricing Prices; null for PRICING. * @param string|null $input_fidelity 'high' or 'low' (gpt-image-1 family only). */ public function __construct( callable $api_key, string $model, Http $http, string $base_url = self::BASE_URL, ?array $pricing = null, ?string $input_fidelity = null ) { $this->api_key = $api_key; $this->model = $model; $this->http = $http; $this->base_url = rtrim( $base_url, '/' ); $this->pricing = $pricing ?? self::PRICING; $this->input_fidelity = $input_fidelity; } /** * Provider ID. */ public function id(): string { return self::ID; } /** * Generates the try-on image. * * @param TryOnInput $input Photo, product images and prompt. */ public function submit( TryOnInput $input ): Submission { $key = (string) ( $this->api_key )(); if ( '' === $key ) { return Submission::failed( new ProviderError( ProviderError::INVALID_KEY ) ); } $size = getimagesizefromstring( $input->person() ); $body = array( 'model' => $this->model, 'prompt' => $input->prompt(), 'images' => $this->images( $input ), 'quality' => $input->quality(), 'size' => OutputSize::for( $this->model, false === $size ? 0 : (int) $size[0], false === $size ? 0 : (int) $size[1] ), 'output_format' => 'jpeg', 'output_compression' => 90, 'n' => 1, ); // Only the gpt-image-1 family accepts input_fidelity; the 2.x family rejects the request when it is present. if ( null !== $this->input_fidelity && 0 === strpos( $this->model, 'gpt-image-1' ) ) { $body['input_fidelity'] = $this->input_fidelity; } // Plain json_encode: this class runs without WordPress in tests, and the body is ASCII plus base64. $json = json_encode( $body, JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode if ( false === $json ) { return Submission::failed( new ProviderError( ProviderError::BAD_REQUEST ) ); } $response = $this->http->request( 'POST', $this->base_url . '/images/edits', array( 'Authorization' => 'Bearer ' . $key, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), $json, self::TIMEOUT ); if ( null === $response ) { return Submission::failed( new ProviderError( ProviderError::CONNECTION ) ); } $data = json_decode( $response['body'], true ); $data = is_array( $data ) ? $data : array(); if ( $response['status'] < 200 || $response['status'] >= 300 ) { return Submission::failed( new ProviderError( ErrorMap::code( $response['status'], self::error_code( $data ) ) ) ); } $b64 = $data['data'][0]['b64_json'] ?? null; $image = is_string( $b64 ) ? base64_decode( $b64, true ) : false; if ( false === $image || '' === $image ) { return Submission::failed( new ProviderError( ProviderError::NO_IMAGE ) ); } $usage = isset( $data['usage'] ) && is_array( $data['usage'] ) ? $data['usage'] : array(); return Submission::done( $image, $this->model, ImageCost::from_usage( $usage, $this->pricing[ $this->model ] ?? null ), $usage ); } /** * Not used: this provider answers synchronously. * * @param string $remote_id Ignored. */ public function poll( string $remote_id ): Submission { return Submission::failed( new ProviderError( ProviderError::UNKNOWN ) ); } /** * Checks the key and model with a free call. '' when they work, otherwise a ProviderError code. */ public function verify(): string { $key = (string) ( $this->api_key )(); if ( '' === $key ) { return ProviderError::INVALID_KEY; } $response = $this->http->request( 'GET', $this->base_url . '/models/' . rawurlencode( $this->model ), array( 'Authorization' => 'Bearer ' . $key, 'Accept' => 'application/json', ), null, 20 ); if ( null === $response ) { return ProviderError::CONNECTION; } if ( $response['status'] >= 200 && $response['status'] < 300 ) { return ''; } $data = json_decode( $response['body'], true ); return ErrorMap::code( $response['status'], self::error_code( is_array( $data ) ? $data : array() ) ); } /** * Image 1 is the shopper, then the product images in prompt order. * * @param TryOnInput $input Input. * @return list */ private function images( TryOnInput $input ): array { $images = array( array( 'image_url' => self::data_url( $input->person() ) ) ); foreach ( $input->references() as $reference ) { $images[] = array( 'image_url' => self::data_url( $reference->bytes() ) ); } return $images; } /** * Data URL of an image. * * @param string $bytes Image bytes. */ private static function data_url( string $bytes ): string { $info = getimagesizefromstring( $bytes ); $mime = false !== $info && in_array( $info['mime'], array( 'image/jpeg', 'image/png', 'image/webp' ), true ) ? $info['mime'] : 'image/jpeg'; return 'data:' . $mime . ';base64,' . base64_encode( $bytes ); } /** * The error.code of a decoded error body, or ''. The message is deliberately not read. * * @param array $data Decoded body. */ private static function error_code( array $data ): string { $code = $data['error']['code'] ?? ''; return is_string( $code ) ? $code : ''; } }