Decouple LM Studio bridge from Open AI in favor of Generic bridge & introduce concrete ModelCatalog to fix examples

This commit is contained in:
Christopher Hertel
2025-12-07 16:31:19 +01:00
parent 656ef6fedb
commit bd6f15492e
13 changed files with 62 additions and 522 deletions

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use Symfony\AI\Platform\Bridge\LmStudio\PlatformFactory;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('LMSTUDIO_HOST_URL'), http_client());
$result = $platform->invoke('text-embedding-nomic-embed-text-v2-moe', <<<TEXT
Once upon a time, there was a country called Japan. It was a beautiful country with a lot of mountains and rivers.
The people of Japan were very kind and hardworking. They loved their country very much and took care of it. The
country was very peaceful and prosperous. The people lived happily ever after.
TEXT);
print_vectors($result);

View File

@@ -1,21 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Bridge\LmStudio;
use Symfony\AI\Platform\Model;
/**
* @author André Lubian <lubiana123@gmail.com>
*/
class Completions extends Model
{
}

View File

@@ -1,46 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Bridge\LmStudio\Completions;
use Symfony\AI\Platform\Bridge\LmStudio\Completions;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @author André Lubian <lubiana123@gmail.com>
*/
final class ModelClient implements ModelClientInterface
{
private readonly EventSourceHttpClient $httpClient;
public function __construct(
HttpClientInterface $httpClient,
private readonly string $hostUrl,
) {
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
}
public function supports(Model $model): bool
{
return $model instanceof Completions;
}
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
return new RawHttpResult($this->httpClient->request('POST', \sprintf('%s/v1/chat/completions', $this->hostUrl), [
'json' => array_merge($options, $payload),
]));
}
}

View File

@@ -1,40 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Bridge\LmStudio\Completions;
use Symfony\AI\Platform\Bridge\LmStudio\Completions;
use Symfony\AI\Platform\Bridge\OpenAi\Gpt\ResultConverter as OpenAiResponseConverter;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\Result\RawResultInterface;
use Symfony\AI\Platform\Result\ResultInterface;
use Symfony\AI\Platform\ResultConverterInterface;
/**
* @author André Lubian <lubiana123@gmail.com>
*/
final class ResultConverter implements ResultConverterInterface
{
public function __construct(
private readonly OpenAiResponseConverter $gptResponseConverter = new OpenAiResponseConverter(),
) {
}
public function supports(Model $model): bool
{
return $model instanceof Completions;
}
public function convert(RawResultInterface $result, array $options = []): ResultInterface
{
return $this->gptResponseConverter->convert($result, $options);
}
}

View File

@@ -1,21 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Bridge\LmStudio;
use Symfony\AI\Platform\Model;
/**
* @author André Lubian <lubiana123@gmail.com>
*/
class Embeddings extends Model
{
}

View File

@@ -1,46 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Bridge\LmStudio\Embeddings;
use Symfony\AI\Platform\Bridge\LmStudio\Embeddings;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
* @author André Lubian <lubiana123@gmail.com>
*/
final class ModelClient implements ModelClientInterface
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $hostUrl,
) {
}
public function supports(Model $model): bool
{
return $model instanceof Embeddings;
}
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
return new RawHttpResult($this->httpClient->request('POST', \sprintf('%s/v1/embeddings', $this->hostUrl), [
'json' => array_merge($options, [
'model' => $model->getName(),
'input' => $payload,
]),
]));
}
}

View File

@@ -1,48 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Bridge\LmStudio\Embeddings;
use Symfony\AI\Platform\Bridge\LmStudio\Embeddings;
use Symfony\AI\Platform\Exception\RuntimeException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\Result\RawResultInterface;
use Symfony\AI\Platform\Result\VectorResult;
use Symfony\AI\Platform\ResultConverterInterface;
use Symfony\AI\Platform\Vector\Vector;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
* @author André Lubian <lubiana123@gmail.com>
*/
final class ResultConverter implements ResultConverterInterface
{
public function supports(Model $model): bool
{
return $model instanceof Embeddings;
}
public function convert(RawResultInterface $result, array $options = []): VectorResult
{
$data = $result->getData();
if (!isset($data['data'])) {
throw new RuntimeException('Response does not contain data.');
}
return new VectorResult(
...array_map(
static fn (array $item): Vector => new Vector($item['embedding']),
$data['data']
),
);
}
}

View File

@@ -11,13 +11,41 @@
namespace Symfony\AI\Platform\Bridge\LmStudio;
use Symfony\AI\Platform\ModelCatalog\FallbackModelCatalog;
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\ModelCatalog\AbstractModelCatalog;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
final class ModelCatalog extends FallbackModelCatalog
final class ModelCatalog extends AbstractModelCatalog
{
// LmStudio can use any model that is loaded locally
// Models are dynamically available based on what's loaded in LmStudio
/**
* @param array<string, array{class: string, capabilities: list<Capability>}> $additionalModels
*/
public function __construct(array $additionalModels = [])
{
$defaultModels = [
'gemma-3-4b-it-qat' => [
'class' => CompletionsModel::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::INPUT_IMAGE,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STREAMING,
Capability::TOOL_CALLING,
],
],
'text-embedding-nomic-embed-text-v2-moe' => [
'class' => EmbeddingsModel::class,
'capabilities' => [
Capability::INPUT_TEXT,
Capability::EMBEDDINGS,
],
],
];
$this->models = array_merge($defaultModels, $additionalModels);
}
}

View File

@@ -12,7 +12,8 @@
namespace Symfony\AI\Platform\Bridge\LmStudio;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\AI\Platform\Bridge\LmStudio\Embeddings\ModelClient;
use Symfony\AI\Platform\Bridge\Generic\Completions;
use Symfony\AI\Platform\Bridge\Generic\Embeddings;
use Symfony\AI\Platform\Contract;
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
use Symfony\AI\Platform\Platform;
@@ -25,7 +26,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
class PlatformFactory
{
public static function create(
string $hostUrl = 'http://localhost:1234',
string $baseUrl = 'http://localhost:1234',
?HttpClientInterface $httpClient = null,
ModelCatalogInterface $modelCatalog = new ModelCatalog(),
?Contract $contract = null,
@@ -35,15 +36,15 @@ class PlatformFactory
return new Platform(
[
new ModelClient($httpClient, $hostUrl),
new Completions\ModelClient($httpClient, $hostUrl),
new Completions\ModelClient($httpClient, $baseUrl),
new Embeddings\ModelClient($httpClient, $baseUrl),
],
[
new Embeddings\ResultConverter(),
new Completions\ResultConverter(),
],
$modelCatalog,
$contract,
$contract ?? Contract::create(),
$eventDispatcher,
);
}

View File

@@ -1,101 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Tests\Bridge\LmStudio\Completions;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\LmStudio\Completions;
use Symfony\AI\Platform\Bridge\LmStudio\Completions\ModelClient;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
class ModelClientTest extends TestCase
{
public function testItIsSupportingTheCorrectModel()
{
$client = new ModelClient(new MockHttpClient(), 'http://localhost:1234');
$this->assertTrue($client->supports(new Completions('test-model')));
}
public function testItIsExecutingTheCorrectRequest()
{
$resultCallback = static function (string $method, string $url, array $options): MockResponse {
self::assertSame('POST', $method);
self::assertSame('http://localhost:1234/v1/chat/completions', $url);
self::assertSame(
'{"model":"test-model","messages":[{"role":"user","content":"Hello, world!"}]}',
$options['body']
);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$client = new ModelClient($httpClient, 'http://localhost:1234');
$payload = [
'model' => 'test-model',
'messages' => [
['role' => 'user', 'content' => 'Hello, world!'],
],
];
$client->request(new Completions('test-model'), $payload);
}
public function testItMergesOptionsWithPayload()
{
$resultCallback = static function (string $method, string $url, array $options): MockResponse {
self::assertSame('POST', $method);
self::assertSame('http://localhost:1234/v1/chat/completions', $url);
self::assertSame(
'{"temperature":0.7,"model":"test-model","messages":[{"role":"user","content":"Hello, world!"}]}',
$options['body']
);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$client = new ModelClient($httpClient, 'http://localhost:1234');
$payload = [
'model' => 'test-model',
'messages' => [
['role' => 'user', 'content' => 'Hello, world!'],
],
];
$client->request(new Completions('test-model'), $payload, ['temperature' => 0.7]);
}
public function testItUsesEventSourceHttpClient()
{
$httpClient = new MockHttpClient();
$client = new ModelClient($httpClient, 'http://localhost:1234');
$reflection = new \ReflectionProperty($client, 'httpClient');
$this->assertInstanceOf(EventSourceHttpClient::class, $reflection->getValue($client));
}
public function testItKeepsExistingEventSourceHttpClient()
{
$eventSourceHttpClient = new EventSourceHttpClient(new MockHttpClient());
$client = new ModelClient($eventSourceHttpClient, 'http://localhost:1234');
$reflection = new \ReflectionProperty($client, 'httpClient');
$this->assertSame($eventSourceHttpClient, $reflection->getValue($client));
}
}

View File

@@ -1,26 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Tests\Bridge\LmStudio\Completions;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\LmStudio\Completions;
use Symfony\AI\Platform\Bridge\LmStudio\Completions\ResultConverter;
class ResultConverterTest extends TestCase
{
public function testItSupportsCompletionsModel()
{
$converter = new ResultConverter();
$this->assertTrue($converter->supports(new Completions('test-model')));
}
}

View File

@@ -1,85 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Tests\Bridge\LmStudio\Embeddings;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\LmStudio\Embeddings;
use Symfony\AI\Platform\Bridge\LmStudio\Embeddings\ModelClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
class ModelClientTest extends TestCase
{
public function testItIsSupportingTheCorrectModel()
{
$client = new ModelClient(new MockHttpClient(), 'http://localhost:1234');
$this->assertTrue($client->supports(new Embeddings('test-model')));
}
public function testItIsExecutingTheCorrectRequest()
{
$resultCallback = static function (string $method, string $url, array $options): MockResponse {
self::assertSame('POST', $method);
self::assertSame('http://localhost:1234/v1/embeddings', $url);
self::assertSame('{"model":"test-model","input":"Hello, world!"}', $options['body']);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$client = new ModelClient($httpClient, 'http://localhost:1234');
$model = new Embeddings('test-model');
$client->request($model, 'Hello, world!');
}
public function testItMergesOptionsWithPayload()
{
$resultCallback = static function (string $method, string $url, array $options): MockResponse {
self::assertSame('POST', $method);
self::assertSame('http://localhost:1234/v1/embeddings', $url);
self::assertSame(
'{"custom_option":"value","model":"test-model","input":"Hello, world!"}',
$options['body']
);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$client = new ModelClient($httpClient, 'http://localhost:1234');
$model = new Embeddings('test-model');
$client->request($model, 'Hello, world!', ['custom_option' => 'value']);
}
public function testItHandlesArrayInput()
{
$resultCallback = static function (string $method, string $url, array $options): MockResponse {
self::assertSame('POST', $method);
self::assertSame('http://localhost:1234/v1/embeddings', $url);
self::assertSame('{"model":"test-model","input":["Hello","world"]}', $options['body']);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$client = new ModelClient($httpClient, 'http://localhost:1234');
$model = new Embeddings('test-model');
$client->request($model, ['Hello', 'world']);
}
}

View File

@@ -1,79 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Tests\Bridge\LmStudio\Embeddings;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\LmStudio\Embeddings;
use Symfony\AI\Platform\Bridge\LmStudio\Embeddings\ResultConverter;
use Symfony\AI\Platform\Exception\RuntimeException;
use Symfony\AI\Platform\Result\RawHttpResult;
use Symfony\Contracts\HttpClient\ResponseInterface;
class ResultConverterTest extends TestCase
{
public function testItConvertsAResponseToAVectorResult()
{
$result = $this->createStub(ResponseInterface::class);
$result
->method('toArray')
->willReturn(
json_decode(
<<<'JSON'
{
"object": "list",
"data": [
{
"object": "embedding",
"index": 0,
"embedding": [0.3, 0.4, 0.4]
},
{
"object": "embedding",
"index": 1,
"embedding": [0.0, 0.0, 0.2]
}
]
}
JSON,
true
)
);
$vectorResult = (new ResultConverter())->convert(new RawHttpResult($result));
$convertedContent = $vectorResult->getContent();
$this->assertCount(2, $convertedContent);
$this->assertSame([0.3, 0.4, 0.4], $convertedContent[0]->getData());
$this->assertSame([0.0, 0.0, 0.2], $convertedContent[1]->getData());
}
public function testItThrowsExceptionWhenResponseDoesNotContainData()
{
$result = $this->createStub(ResponseInterface::class);
$result
->method('toArray')
->willReturn(['invalid' => 'response']);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Response does not contain data');
(new ResultConverter())->convert(new RawHttpResult($result));
}
public function testItSupportsEmbeddingsModel()
{
$converter = new ResultConverter();
$this->assertTrue($converter->supports(new Embeddings('test-model')));
}
}