diff --git a/.env b/.env index 6f06a602..6f974eb3 100644 --- a/.env +++ b/.env @@ -25,6 +25,9 @@ AZURE_OPENAI_KEY= AZURE_LLAMA_BASEURL= AZURE_LLAMA_KEY= +# Hugging Face Access Token +HUGGINGFACE_KEY= + # For using OpenRouter OPENROUTER_KEY= diff --git a/Makefile b/Makefile index 1a046139..957b90ba 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,10 @@ coverage: run-examples: ./example + ./huggingface + +make huggingface-models: + php examples/huggingface/_model-listing.php ci: ci-stable diff --git a/README.md b/README.md index ac0a125f..4138e37b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # LLM Chain -PHP library for building LLM-based features and applications. +PHP library for building LLM-based and AI-based features and applications. -This library is not a stable yet, but still rather experimental. Feel free to try it out, give feedback, ask questions, contribute or share your use cases. -Abstractions, concepts and interfaces are not final and potentially subject of change. +This library is not a stable yet, but still rather experimental. Feel free to try it out, give feedback, ask questions, +contribute or share your use cases. Abstractions, concepts and interfaces are not final and potentially subject of change. ## Requirements @@ -25,7 +25,7 @@ See [examples](examples) folder to run example implementations using this librar Depending on the example you need to export different environment variables for API keys or deployment configurations or create a `.env.local` based on `.env` file. -To run all examples, use `make run-examples` or `php example`. +To run all examples, use `make run-examples` or `php example` and `php huggingface` for all HuggingFace related examples. For a more sophisticated demo, see the [Symfony Demo Application](https://github.com/php-llm/symfony-demo). @@ -33,7 +33,8 @@ For a more sophisticated demo, see the [Symfony Demo Application](https://github ### Models & Platforms -LLM Chain categorizes two main types of models: **Language Models** and **Embeddings Models**. +LLM Chain categorizes two main types of models: **Language Models** and **Embeddings Models**. On top of that, there are +other models, like text-to-speech, image generation or classification models that are also supported. Language Models, like GPT, Claude and Llama, as essential centerpiece of LLM applications and Embeddings Models as supporting models to provide vector representations of text. @@ -71,6 +72,8 @@ $embeddings = new Embeddings(); * Other Models * [OpenAI's Dall·E](https://platform.openai.com/docs/guides/image-generation) with [OpenAI](https://platform.openai.com/docs/overview) as Platform * [OpenAI's Whisper](https://platform.openai.com/docs/guides/speech-to-text) with [OpenAI](https://platform.openai.com/docs/overview) and [Azure](https://learn.microsoft.com/azure/ai-services/openai/concepts/models) as Platform + * All models provided by [HuggingFace](https://huggingface.co/) can be listed with `make huggingface-models` + And more filtered with `php examples/huggingface/_model-listing.php --provider=hf-inference --task=object-detection` See [issue #28](https://github.com/php-llm/llm-chain/issues/28) for planned support of other models and platforms. @@ -725,6 +728,51 @@ final class MyProcessor implements OutputProcessor, ChainAwareProcessor } ``` +## HuggingFace + +LLM Chain comes out of the box with an integration for [HuggingFace](https://huggingface.co/) which is a platform for +hosting and sharing all kind of models, including LLMs, embeddings, image generation and classification models. + +You can just instantiate the Platform with the corresponding HuggingFace bridge and use it with the `task` option: +```php +use PhpLlm\LlmChain\Bridge\HuggingFace\Model; +use PhpLlm\LlmChain\Bridge\HuggingFace\PlatformFactory; +use PhpLlm\LlmChain\Bridge\HuggingFace\Task; +use PhpLlm\LlmChain\Model\Message\Content\Image; + +$platform = PlatformFactory::create($apiKey); +$model = new Model('facebook/detr-resnet-50'); + +$image = Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'); +$response = $platform->request($model, $image, [ + 'task' => Task::OBJECT_DETECTION, // defining a task is mandatory for internal request & response handling +]); + +dump($response->getContent()); +``` + +#### Code Examples + +1. [Audio Classification](examples/huggingface/audio-classification.php) +1. [Automatic Speech Recognition](examples/huggingface/automatic-speech-recognition.php) +1. [Chat Completion](examples/huggingface/chat-completion.php) +1. [Feature Extraction (Embeddings)](examples/huggingface/feature-extraction.php) +1. [Fill Mask](examples/huggingface/fill-mask.php) +1. [Image Classification](examples/huggingface/image-classification.php) +1. [Image Segmentation.php](examples/huggingface/image-segmentation.php) +1. [Image-to-Text](examples/huggingface/image-to-text.php) +1. [Object Detection](examples/huggingface/object-detection.php) +1. [Question Answering](examples/huggingface/question-answering.php) +1. [Sentence Similarity](examples/huggingface/sentence-similarity.php) +1. [Summarization](examples/huggingface/summarization.php) +1. [Table Question Answering](examples/huggingface/table-question-answering.php) +1. [Text Classification](examples/huggingface/text-classification.php) +1. [Text Generation](examples/huggingface/text-generation.php) +1. [Text-to-Image](examples/huggingface/text-to-image.php) +1. [Token Classification](examples/huggingface/token-classification.php) +1. [Translation](examples/huggingface/translation.php) +1. [Zero-shot Classification](examples/huggingface/zero-shot-classification.php) + ## Contributions Contributions are always welcome, so feel free to join the development of this library. To get started, please read the diff --git a/examples/huggingface/_model-listing.php b/examples/huggingface/_model-listing.php new file mode 100644 index 00000000..37495ca3 --- /dev/null +++ b/examples/huggingface/_model-listing.php @@ -0,0 +1,39 @@ +setDescription('Lists all available models on HuggingFace') + ->addOption('provider', 'p', InputOption::VALUE_REQUIRED, 'Name of the inference provider to filter models by') + ->addOption('task', 't', InputOption::VALUE_REQUIRED, 'Name of the task to filter models by') + ->setCode(function (InputInterface $input, ConsoleOutput $output) { + $io = new SymfonyStyle($input, $output); + $io->title('HuggingFace Model Listing'); + + $provider = $input->getOption('provider'); + $task = $input->getOption('task'); + + $models = (new ApiClient())->models($provider, $task); + + if (0 === count($models)) { + $io->error('No models found for the given provider and task.'); + + return Command::FAILURE; + } + + $io->listing( + array_map(fn (Model $model) => $model->getName(), $models) + ); + + return Command::SUCCESS; + }) + ->run(); diff --git a/examples/huggingface/audio-classification.php b/examples/huggingface/audio-classification.php new file mode 100644 index 00000000..98642170 --- /dev/null +++ b/examples/huggingface/audio-classification.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('MIT/ast-finetuned-audioset-10-10-0.4593'); +$audio = Audio::fromFile(dirname(__DIR__, 2).'/tests/Fixture/audio.mp3'); + +$response = $platform->request($model, $audio, [ + 'task' => Task::AUDIO_CLASSIFICATION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/automatic-speech-recognition.php b/examples/huggingface/automatic-speech-recognition.php new file mode 100644 index 00000000..93117a59 --- /dev/null +++ b/examples/huggingface/automatic-speech-recognition.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('openai/whisper-large-v3'); +$audio = Audio::fromFile(dirname(__DIR__, 2).'/tests/Fixture/audio.mp3'); + +$response = $platform->request($model, $audio, [ + 'task' => Task::AUTOMATIC_SPEECH_RECOGNITION, +]); + +echo $response->getContent().PHP_EOL; diff --git a/examples/huggingface/chat-completion.php b/examples/huggingface/chat-completion.php new file mode 100644 index 00000000..97649668 --- /dev/null +++ b/examples/huggingface/chat-completion.php @@ -0,0 +1,26 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('HuggingFaceH4/zephyr-7b-beta'); + +$messages = new MessageBag(Message::ofUser('Hello, how are you doing today?')); +$response = $platform->request($model, $messages, [ + 'task' => Task::CHAT_COMPLETION, +]); + +echo $response->getContent().PHP_EOL; diff --git a/examples/huggingface/feature-extraction.php b/examples/huggingface/feature-extraction.php new file mode 100644 index 00000000..2322a3dc --- /dev/null +++ b/examples/huggingface/feature-extraction.php @@ -0,0 +1,26 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('thenlper/gte-large'); + +$response = $platform->request($model, 'Today is a sunny day and I will get some ice cream.', [ + 'task' => Task::FEATURE_EXTRACTION, +]); + +assert($response instanceof VectorResponse); + +echo 'Dimensions: '.$response->getContent()[0]->getDimensions().PHP_EOL; diff --git a/examples/huggingface/fill-mask.php b/examples/huggingface/fill-mask.php new file mode 100644 index 00000000..e6373ea2 --- /dev/null +++ b/examples/huggingface/fill-mask.php @@ -0,0 +1,23 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('FacebookAI/xlm-roberta-base'); + +$response = $platform->request($model, 'Hello I\'m a model.', [ + 'task' => Task::FILL_MASK, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/image-classification.php b/examples/huggingface/image-classification.php new file mode 100644 index 00000000..479526b9 --- /dev/null +++ b/examples/huggingface/image-classification.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('google/vit-base-patch16-224'); + +$image = Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'); +$response = $platform->request($model, $image, [ + 'task' => Task::IMAGE_CLASSIFICATION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/image-segmentation.php b/examples/huggingface/image-segmentation.php new file mode 100644 index 00000000..a361b125 --- /dev/null +++ b/examples/huggingface/image-segmentation.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('nvidia/segformer-b0-finetuned-ade-512-512'); + +$image = Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'); +$response = $platform->request($model, $image, [ + 'task' => Task::IMAGE_SEGMENTATION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/image-to-text.php b/examples/huggingface/image-to-text.php new file mode 100644 index 00000000..90e09751 --- /dev/null +++ b/examples/huggingface/image-to-text.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('Salesforce/blip-image-captioning-base'); + +$image = Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'); +$response = $platform->request($model, $image, [ + 'task' => Task::IMAGE_TO_TEXT, +]); + +echo $response->getContent().PHP_EOL; diff --git a/examples/huggingface/object-detection.php b/examples/huggingface/object-detection.php new file mode 100644 index 00000000..8bc3d362 --- /dev/null +++ b/examples/huggingface/object-detection.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('facebook/detr-resnet-50'); + +$image = Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'); +$response = $platform->request($model, $image, [ + 'task' => Task::OBJECT_DETECTION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/question-answering.php b/examples/huggingface/question-answering.php new file mode 100644 index 00000000..33c3e345 --- /dev/null +++ b/examples/huggingface/question-answering.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('deepset/roberta-base-squad2'); + +$input = [ + 'question' => 'What is the capital of France?', + 'context' => 'Paris is the capital and most populous city of France, with an estimated population of 2,175,601 residents as of 2018, in an area of more than 105 square kilometres.', +]; + +$response = $platform->request($model, $input, [ + 'task' => Task::QUESTION_ANSWERING, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/sentence-similarity.php b/examples/huggingface/sentence-similarity.php new file mode 100644 index 00000000..243da7cf --- /dev/null +++ b/examples/huggingface/sentence-similarity.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('sentence-transformers/all-MiniLM-L6-v2'); + +$input = [ + 'source_sentence' => 'That is a happy dog', + 'sentences' => [ + 'That is a happy canine', + 'That is a happy cat', + 'Today is a sunny day', + ], +]; + +$response = $platform->request($model, $input, [ + 'task' => Task::SENTENCE_SIMILARITY, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/summarization.php b/examples/huggingface/summarization.php new file mode 100644 index 00000000..d3c6ee3b --- /dev/null +++ b/examples/huggingface/summarization.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('facebook/bart-large-cnn'); + +$longText = <<request($model, $longText, [ + 'task' => Task::SUMMARIZATION, +]); + +echo $response->getContent().PHP_EOL; diff --git a/examples/huggingface/table-question-answering.php b/examples/huggingface/table-question-answering.php new file mode 100644 index 00000000..7b475911 --- /dev/null +++ b/examples/huggingface/table-question-answering.php @@ -0,0 +1,31 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('microsoft/tapex-base'); + +$input = [ + 'query' => 'select year where city = beijing', + 'table' => [ + 'year' => [1896, 1900, 1904, 2004, 2008, 2012], + 'city' => ['athens', 'paris', 'st. louis', 'athens', 'beijing', 'london'], + ], +]; + +$response = $platform->request($model, $input, [ + 'task' => Task::TABLE_QUESTION_ANSWERING, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/text-classification.php b/examples/huggingface/text-classification.php new file mode 100644 index 00000000..3e95c41a --- /dev/null +++ b/examples/huggingface/text-classification.php @@ -0,0 +1,23 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('ProsusAI/finbert'); + +$response = $platform->request($model, 'I like you. I love you.', [ + 'task' => Task::TEXT_CLASSIFICATION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/text-generation.php b/examples/huggingface/text-generation.php new file mode 100644 index 00000000..878eff3b --- /dev/null +++ b/examples/huggingface/text-generation.php @@ -0,0 +1,23 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('gpt2'); + +$response = $platform->request($model, 'The quick brown fox jumps over the lazy', [ + 'task' => Task::TEXT_GENERATION, +]); + +echo $response->getContent().PHP_EOL; diff --git a/examples/huggingface/text-to-image.php b/examples/huggingface/text-to-image.php new file mode 100644 index 00000000..ff27bc55 --- /dev/null +++ b/examples/huggingface/text-to-image.php @@ -0,0 +1,26 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('black-forest-labs/FLUX.1-dev'); + +$response = $platform->request($model, 'Astronaut riding a horse', [ + 'task' => Task::TEXT_TO_IMAGE, +]); + +assert($response instanceof BinaryResponse); + +echo $response->toBase64().PHP_EOL; diff --git a/examples/huggingface/token-classification.php b/examples/huggingface/token-classification.php new file mode 100644 index 00000000..a5bc7a93 --- /dev/null +++ b/examples/huggingface/token-classification.php @@ -0,0 +1,23 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('dbmdz/bert-large-cased-finetuned-conll03-english'); + +$response = $platform->request($model, 'John Smith works at Microsoft in London.', [ + 'task' => Task::TOKEN_CLASSIFICATION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/translation.php b/examples/huggingface/translation.php new file mode 100644 index 00000000..7e001e62 --- /dev/null +++ b/examples/huggingface/translation.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('facebook/mbart-large-50-many-to-many-mmt'); + +$response = $platform->request($model, 'Меня зовут Вольфганг и я живу в Берлине', [ + 'task' => Task::TRANSLATION, + 'src_lang' => 'ru', + 'tgt_lang' => 'en', +]); + +echo $response->getContent().PHP_EOL; diff --git a/examples/huggingface/zero-shot-classification.php b/examples/huggingface/zero-shot-classification.php new file mode 100644 index 00000000..b8acadce --- /dev/null +++ b/examples/huggingface/zero-shot-classification.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('facebook/bart-large-mnli'); + +$text = 'Hi, I recently bought a device from your company but it is not working as advertised and I would like to get reimbursed!'; +$response = $platform->request($model, $text, [ + 'task' => Task::ZERO_SHOT_CLASSIFICATION, + 'candidate_labels' => ['refund', 'legal', 'faq'], +]); + +dump($response->getContent()); diff --git a/huggingface b/huggingface new file mode 100755 index 00000000..dc7fff5e --- /dev/null +++ b/huggingface @@ -0,0 +1,102 @@ +#!/usr/bin/env php +setDescription('Runs all HuggingFace examples in folder examples/huggingface/') + ->setCode(function (InputInterface $input, ConsoleOutput $output) { + $io = new SymfonyStyle($input, $output); + $io->title('HuggingFace Examples'); + + $examples = (new Finder()) + ->in(__DIR__.'/examples/huggingface') + ->name('*.php') + ->sortByName() + ->files(); + + /** @var array{example: SplFileInfo, process: Process} $exampleRuns */ + $exampleRuns = []; + foreach ($examples as $example) { + $exampleRuns[] = [ + 'example' => $example, + 'process' => $process = new Process(['php', $example->getRealPath()]), + ]; + $process->start(); + } + + $examplesRunning = fn () => array_reduce($exampleRuns, fn ($running, $example) => $running || $example['process']->isRunning(), false); + $examplesSuccessful = fn () => array_reduce($exampleRuns, fn ($successful, $example) => $successful && $example['process']->isSuccessful(), true); + + $section = $output->section(); + $renderTable = function () use ($exampleRuns, $section) { + $section->clear(); + $table = new Table($section); + $table->setHeaders(['Example', 'State', 'Output']); + foreach ($exampleRuns as $run) { + /** @var SplFileInfo $example */ + /** @var Process $process */ + ['example' => $example, 'process' => $process] = $run; + + $output = str_replace(PHP_EOL, ' ', $process->getOutput()); + $output = strlen($output) <= 100 ? $output : substr($output, 0, 100).'...'; + $emptyOutput = 0 === strlen(trim($output)); + + $state = 'Running'; + if ($process->isTerminated()) { + $success = $process->isSuccessful() && !$emptyOutput; + $state = $success ? 'Finished' + : (1 === $run['process']->getExitCode() || $emptyOutput ? 'Failed' : 'Skipped'); + } + + $table->addRow([$example->getFilename(), $state, $output]); + } + $table->render(); + }; + + while ($examplesRunning()) { + $renderTable(); + sleep(1); + } + + $renderTable(); + + $io->newLine(); + + // Count successful examples + $successCount = array_reduce($exampleRuns, function ($count, $example) { + if ($example['process']->isSuccessful() && strlen(trim($example['process']->getOutput())) > 0) { + return $count + 1; + } + return $count; + }, 0); + + $totalCount = count($exampleRuns); + + if ($successCount < $totalCount) { + $io->warning("$successCount out of $totalCount examples ran successfully. Some examples may have failed due to API limits or unavailable models."); + } else { + $io->success("All $totalCount examples ran successfully!"); + } + + // Display detailed error information for failed examples + foreach ($exampleRuns as $run) { + if (!$run['process']->isSuccessful()) { + $io->section('Error in ' . $run['example']->getFilename()); + $io->text($run['process']->getErrorOutput()); + } + } + + return Command::SUCCESS; + }) + ->run(); diff --git a/src/Bridge/HuggingFace/ApiClient.php b/src/Bridge/HuggingFace/ApiClient.php new file mode 100644 index 00000000..552fef77 --- /dev/null +++ b/src/Bridge/HuggingFace/ApiClient.php @@ -0,0 +1,32 @@ +httpClient = $httpClient ?? HttpClient::create(); + } + + /** + * @return Model[] + */ + public function models(?string $provider, ?string $task): array + { + $response = $this->httpClient->request('GET', 'https://huggingface.co/api/models', [ + 'query' => [ + 'inference_provider' => $provider, + 'pipeline_tag' => $task, + ], + ]); + + return array_map(fn (array $model) => new Model($model['id']), $response->toArray()); + } +} diff --git a/src/Bridge/HuggingFace/Model.php b/src/Bridge/HuggingFace/Model.php new file mode 100644 index 00000000..8aadff4f --- /dev/null +++ b/src/Bridge/HuggingFace/Model.php @@ -0,0 +1,30 @@ + $options + */ + public function __construct( + private ?string $name = null, + private array $options = [], + ) { + } + + public function getName(): string + { + return $this->name ?? ''; + } + + public function getOptions(): array + { + return $this->options; + } +} diff --git a/src/Bridge/HuggingFace/ModelClient.php b/src/Bridge/HuggingFace/ModelClient.php new file mode 100644 index 00000000..b955e060 --- /dev/null +++ b/src/Bridge/HuggingFace/ModelClient.php @@ -0,0 +1,104 @@ +httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(BaseModel $model, object|array|string $input): bool + { + return $model instanceof Model; + } + + public function request(BaseModel $model, object|array|string $input, array $options = []): ResponseInterface + { + Assert::isInstanceOf($model, Model::class); + $task = $options['task'] ?? null; + unset($options['task']); + + return $this->httpClient->request('POST', $this->getUrl($model, $input, $task), [ + 'auth_bearer' => $this->apiKey, + ...$this->getPayload($input, $options), + ]); + } + + /** + * @param array|string|object $input + */ + private function getUrl(Model $model, object|array|string $input, ?string $task): string + { + $endpoint = Task::FEATURE_EXTRACTION === $task ? 'pipeline/feature-extraction' : 'models'; + $url = sprintf('https://router.huggingface.co/%s/%s/%s', $this->provider, $endpoint, $model->getName()); + + if ($input instanceof MessageBagInterface) { + $url .= '/v1/chat/completions'; + } + + return $url; + } + + /** + * @param array|string|object $input + * @param array $options + * + * @return array + */ + private function getPayload(object|array|string $input, array $options): array + { + if ($input instanceof Audio || $input instanceof Image) { + return [ + 'headers' => ['Content-Type' => $input->getFormat()], + 'body' => $input->asBinary(), + ]; + } + + if ($input instanceof MessageBagInterface) { + return [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => [ + 'messages' => $input, + ...$options, + ], + ]; + } + + if (is_string($input) || is_array($input)) { + $payload = [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => [ + 'inputs' => $input, + ], + ]; + + if (0 !== count($options)) { + $payload['json']['parameters'] = $options; + } + + return $payload; + } + + throw new \InvalidArgumentException('Unsupported input type: '.get_debug_type($input)); + } +} diff --git a/src/Bridge/HuggingFace/Output/Classification.php b/src/Bridge/HuggingFace/Output/Classification.php new file mode 100644 index 00000000..8474f492 --- /dev/null +++ b/src/Bridge/HuggingFace/Output/Classification.php @@ -0,0 +1,14 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + array_map(fn (array $item) => new Classification($item['label'], $item['score']), $data) + ); + } +} diff --git a/src/Bridge/HuggingFace/Output/DetectedObject.php b/src/Bridge/HuggingFace/Output/DetectedObject.php new file mode 100644 index 00000000..199159ec --- /dev/null +++ b/src/Bridge/HuggingFace/Output/DetectedObject.php @@ -0,0 +1,18 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self(array_map( + fn (array $item) => new MaskFill( + $item['token'], + $item['token_str'], + $item['sequence'], + $item['score'], + ), + $data, + )); + } +} diff --git a/src/Bridge/HuggingFace/Output/ImageSegment.php b/src/Bridge/HuggingFace/Output/ImageSegment.php new file mode 100644 index 00000000..f3764589 --- /dev/null +++ b/src/Bridge/HuggingFace/Output/ImageSegment.php @@ -0,0 +1,15 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + array_map(fn (array $item) => new ImageSegment($item['label'], $item['score'], $item['mask']), $data) + ); + } +} diff --git a/src/Bridge/HuggingFace/Output/MaskFill.php b/src/Bridge/HuggingFace/Output/MaskFill.php new file mode 100644 index 00000000..56c48346 --- /dev/null +++ b/src/Bridge/HuggingFace/Output/MaskFill.php @@ -0,0 +1,16 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self(array_map( + fn (array $item) => new DetectedObject( + $item['label'], + $item['score'], + $item['box']['xmin'], + $item['box']['ymin'], + $item['box']['xmax'], + $item['box']['ymax'], + ), + $data, + )); + } +} diff --git a/src/Bridge/HuggingFace/Output/QuestionAnsweringResult.php b/src/Bridge/HuggingFace/Output/QuestionAnsweringResult.php new file mode 100644 index 00000000..49dbdecf --- /dev/null +++ b/src/Bridge/HuggingFace/Output/QuestionAnsweringResult.php @@ -0,0 +1,29 @@ + $similarities + */ + public function __construct( + public array $similarities, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self($data); + } +} diff --git a/src/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php b/src/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php new file mode 100644 index 00000000..1121ea4a --- /dev/null +++ b/src/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php @@ -0,0 +1,31 @@ + $cells + * @param array $aggregator + */ + public function __construct( + public string $answer, + public array $cells = [], + public array $aggregator = [], + ) { + } + + /** + * @param array{answer: string, cells?: array, aggregator?: array} $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['answer'], + $data['cells'] ?? [], + $data['aggregator'] ?? [], + ); + } +} diff --git a/src/Bridge/HuggingFace/Output/Token.php b/src/Bridge/HuggingFace/Output/Token.php new file mode 100644 index 00000000..a1161431 --- /dev/null +++ b/src/Bridge/HuggingFace/Output/Token.php @@ -0,0 +1,17 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self(array_map( + fn (array $item) => new Token( + $item['entity_group'], + $item['score'], + $item['word'], + $item['start'], + $item['end'], + ), + $data, + )); + } +} diff --git a/src/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php b/src/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php new file mode 100644 index 00000000..462e6514 --- /dev/null +++ b/src/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php @@ -0,0 +1,31 @@ + $labels + * @param array $scores + */ + public function __construct( + public array $labels, + public array $scores, + public ?string $sequence = null, + ) { + } + + /** + * @param array{labels: array, scores: array, sequence?: string} $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['labels'], + $data['scores'], + $data['sequence'] ?? null, + ); + } +} diff --git a/src/Bridge/HuggingFace/PlatformFactory.php b/src/Bridge/HuggingFace/PlatformFactory.php new file mode 100644 index 00000000..0a1b3dd1 --- /dev/null +++ b/src/Bridge/HuggingFace/PlatformFactory.php @@ -0,0 +1,23 @@ +getStatusCode()) { + return throw new \RuntimeException('Service unavailable.'); + } + + if (404 === $response->getStatusCode()) { + return throw new \InvalidArgumentException('Model, provider or task not found (404).'); + } + + $headers = $response->getHeaders(false); + $contentType = $headers['content-type'][0] ?? null; + $content = 'application/json' === $contentType ? $response->toArray(false) : $response->getContent(false); + + if (str_starts_with((string) $response->getStatusCode(), '4')) { + $message = is_string($content) ? $content : + (is_array($content['error']) ? $content['error'][0] : $content['error']); + + throw new \InvalidArgumentException(sprintf('API Client Error (%d): %s', $response->getStatusCode(), $message)); + } + + if (200 !== $response->getStatusCode()) { + throw new \RuntimeException('Unhandled response code: '.$response->getStatusCode()); + } + + $task = $options['task'] ?? null; + + return match ($task) { + Task::AUDIO_CLASSIFICATION, Task::IMAGE_CLASSIFICATION => new StructuredResponse( + ClassificationResult::fromArray($content) + ), + Task::AUTOMATIC_SPEECH_RECOGNITION => new TextResponse($content['text'] ?? ''), + Task::CHAT_COMPLETION => new TextResponse($content['choices'][0]['message']['content'] ?? ''), + Task::FEATURE_EXTRACTION => new VectorResponse(new Vector($content)), + Task::TEXT_CLASSIFICATION => new StructuredResponse(ClassificationResult::fromArray(reset($content) ?? [])), + Task::FILL_MASK => new StructuredResponse(FillMaskResult::fromArray($content)), + Task::IMAGE_SEGMENTATION => new StructuredResponse(ImageSegmentationResult::fromArray($content)), + Task::IMAGE_TO_TEXT, Task::TEXT_GENERATION => new TextResponse($content[0]['generated_text'] ?? ''), + Task::TEXT_TO_IMAGE => new BinaryResponse($content, $contentType), + Task::OBJECT_DETECTION => new StructuredResponse(ObjectDetectionResult::fromArray($content)), + Task::QUESTION_ANSWERING => new StructuredResponse(QuestionAnsweringResult::fromArray($content)), + Task::SENTENCE_SIMILARITY => new StructuredResponse(SentenceSimilarityResult::fromArray($content)), + Task::SUMMARIZATION => new TextResponse($content[0]['summary_text']), + Task::TABLE_QUESTION_ANSWERING => new StructuredResponse(TableQuestionAnsweringResult::fromArray(dump($content))), + Task::TOKEN_CLASSIFICATION => new StructuredResponse(TokenClassificationResult::fromArray($content)), + Task::TRANSLATION => new TextResponse($content[0]['translation_text'] ?? ''), + Task::ZERO_SHOT_CLASSIFICATION => new StructuredResponse(ZeroShotClassificationResult::fromArray($content)), + + default => throw new \RuntimeException(sprintf('Unsupported task: %s', $task)), + }; + } +} diff --git a/src/Bridge/HuggingFace/Task.php b/src/Bridge/HuggingFace/Task.php new file mode 100644 index 00000000..ca4c2d0f --- /dev/null +++ b/src/Bridge/HuggingFace/Task.php @@ -0,0 +1,28 @@ +data; + } + + public function toBase64(): string + { + return \base64_encode($this->data); + } + + public function toDataUri(): string + { + if (null === $this->mimeType) { + throw new \RuntimeException('Mime type is not set.'); + } + + return 'data:'.$this->mimeType.';base64,'.$this->toBase64(); + } +} diff --git a/tests/Bridge/HuggingFace/ModelClientTest.php b/tests/Bridge/HuggingFace/ModelClientTest.php new file mode 100644 index 00000000..fc1ad767 --- /dev/null +++ b/tests/Bridge/HuggingFace/ModelClientTest.php @@ -0,0 +1,177 @@ +supports($model, 'test-input')); + } + + public function testSupportsWithNonHuggingFaceModel(): void + { + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); + $model = $this->createMock(BaseModel::class); + + self::assertFalse($modelClient->supports($model, 'test-input')); + } + + public function testRequestWithUnsupportedInputType(): void + { + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); + $model = new Model('test-model'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported input type: stdClass'); + + $modelClient->request($model, new \stdClass()); + } + + public function testRequestWithNonHuggingFaceModel(): void + { + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); + $model = $this->createMock(BaseModel::class); + + $this->expectException(\InvalidArgumentException::class); + + $modelClient->request($model, 'test input'); + } + + #[DataProvider('urlTestCases')] + public function testGetUrlForDifferentInputsAndTasks(object|array|string $input, ?string $task, string $expectedUrl): void + { + $reflection = new \ReflectionClass(ModelClient::class); + $getUrlMethod = $reflection->getMethod('getUrl'); + $getUrlMethod->setAccessible(true); + + $model = new Model('test-model'); + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); + + $actualUrl = $getUrlMethod->invoke($modelClient, $model, $input, $task); + + self::assertEquals($expectedUrl, $actualUrl); + } + + public static function urlTestCases(): \Iterator + { + $messageBag = new MessageBag(); + $messageBag->add(new UserMessage(new Text('Test message'))); + yield 'string input' => [ + 'input' => 'Hello world', + 'task' => null, + 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model', + ]; + yield 'array input' => [ + 'input' => ['text' => 'Hello world'], + 'task' => null, + 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model', + ]; + yield 'image input' => [ + 'input' => Image::fromDataUrl('data:image/jpeg;base64,/9j/Cg=='), + 'task' => null, + 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model', + ]; + yield 'feature extraction' => [ + 'input' => 'Extract features', + 'task' => Task::FEATURE_EXTRACTION, + 'expectedUrl' => 'https://router.huggingface.co/test-provider/pipeline/feature-extraction/test-model', + ]; + yield 'message bag' => [ + 'input' => $messageBag, + 'task' => null, + 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model/v1/chat/completions', + ]; + } + + #[DataProvider('payloadTestCases')] + public function testGetPayloadForDifferentInputsAndTasks(object|array|string $input, ?string $task, array $options, array $expectedKeys, array $expectedValues = []): void + { + $reflection = new \ReflectionClass(ModelClient::class); + $getPayloadMethod = $reflection->getMethod('getPayload'); + $getPayloadMethod->setAccessible(true); + + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); + + $payload = $getPayloadMethod->invoke($modelClient, $input, $options); + + // Check that expected keys exist + foreach ($expectedKeys as $key) { + self::assertArrayHasKey($key, $payload); + } + + // Check expected values if specified + foreach ($expectedValues as $path => $value) { + $keys = explode('.', $path); + $current = $payload; + foreach ($keys as $key) { + self::assertArrayHasKey($key, $current); + $current = $current[$key]; + } + + self::assertEquals($value, $current); + } + } + + public static function payloadTestCases(): \Iterator + { + $messageBag = new MessageBag(); + $messageBag->add(new UserMessage(new Text('Test message'))); + yield 'string input' => [ + 'input' => 'Hello world', + 'task' => null, + 'options' => [], + 'expectedKeys' => ['headers', 'json'], + 'expectedValues' => [ + 'headers.Content-Type' => 'application/json', + 'json.inputs' => 'Hello world', + ], + ]; + yield 'array input' => [ + 'input' => ['text' => 'Hello world'], + 'task' => null, + 'options' => ['temperature' => 0.7], + 'expectedKeys' => ['headers', 'json'], + 'expectedValues' => [ + 'headers.Content-Type' => 'application/json', + 'json.inputs' => ['text' => 'Hello world'], + 'json.parameters.temperature' => 0.7, + ], + ]; + yield 'message bag' => [ + 'input' => $messageBag, + 'task' => null, + 'options' => ['max_tokens' => 100], + 'expectedKeys' => ['headers', 'json'], + 'expectedValues' => [ + 'headers.Content-Type' => 'application/json', + 'json.max_tokens' => 100, + ], + ]; + } +}