Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

Commit 4c8003c

Browse files
committed
feat: Add comprehensive unit tests for Albert API integration
1 parent 3d3113d commit 4c8003c

File tree

6 files changed

+459
-16
lines changed

6 files changed

+459
-16
lines changed

src/Platform/Bridge/Albert/EmbeddingsModelClient.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
namespace PhpLlm\LlmChain\Platform\Bridge\Albert;
66

77
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings;
8+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
89
use PhpLlm\LlmChain\Platform\Model;
910
use PhpLlm\LlmChain\Platform\ModelClientInterface;
1011
use Symfony\Contracts\HttpClient\HttpClientInterface;
1112
use Symfony\Contracts\HttpClient\ResponseInterface;
12-
use Webmozart\Assert\Assert;
1313

1414
final readonly class EmbeddingsModelClient implements ModelClientInterface
1515
{
@@ -19,8 +19,12 @@ public function __construct(
1919
private string $apiKey,
2020
private string $baseUrl,
2121
) {
22-
Assert::stringNotEmpty($apiKey, 'The API key must not be empty.');
23-
Assert::stringNotEmpty($baseUrl, 'The base URL must not be empty.');
22+
if ('' === $apiKey) {
23+
throw new InvalidArgumentException('The API key must not be empty.');
24+
}
25+
if ('' === $baseUrl) {
26+
throw new InvalidArgumentException('The base URL must not be empty.');
27+
}
2428
}
2529

2630
public function supports(Model $model): bool
@@ -30,9 +34,9 @@ public function supports(Model $model): bool
3034

3135
public function request(Model $model, array|string $payload, array $options = []): ResponseInterface
3236
{
33-
return $this->httpClient->request('POST', $this->baseUrl.'embeddings', [
37+
return $this->httpClient->request('POST', rtrim($this->baseUrl, '/').'/embeddings', [
3438
'auth_bearer' => $this->apiKey,
35-
'json' => array_merge($options, $payload),
39+
'json' => is_array($payload) ? array_merge($payload, $options) : $payload,
3640
]);
3741
}
3842
}

src/Platform/Bridge/Albert/GPTModelClient.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
namespace PhpLlm\LlmChain\Platform\Bridge\Albert;
66

77
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
8+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
89
use PhpLlm\LlmChain\Platform\Model;
910
use PhpLlm\LlmChain\Platform\ModelClientInterface;
1011
use Symfony\Component\HttpClient\EventSourceHttpClient;
1112
use Symfony\Contracts\HttpClient\HttpClientInterface;
1213
use Symfony\Contracts\HttpClient\ResponseInterface;
13-
use Webmozart\Assert\Assert;
1414

1515
final readonly class GPTModelClient implements ModelClientInterface
1616
{
@@ -23,8 +23,12 @@ public function __construct(
2323
private string $baseUrl,
2424
) {
2525
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
26-
Assert::stringNotEmpty($apiKey, 'The API key must not be empty.');
27-
Assert::stringNotEmpty($baseUrl, 'The base URL must not be empty.');
26+
if ('' === $apiKey) {
27+
throw new InvalidArgumentException('The API key must not be empty.');
28+
}
29+
if ('' === $baseUrl) {
30+
throw new InvalidArgumentException('The base URL must not be empty.');
31+
}
2832
}
2933

3034
public function supports(Model $model): bool
@@ -34,9 +38,9 @@ public function supports(Model $model): bool
3438

3539
public function request(Model $model, array|string $payload, array $options = []): ResponseInterface
3640
{
37-
return $this->httpClient->request('POST', $this->baseUrl.'chat/completions', [
41+
return $this->httpClient->request('POST', rtrim($this->baseUrl, '/').'/chat/completions', [
3842
'auth_bearer' => $this->apiKey,
39-
'json' => array_merge($options, $payload),
43+
'json' => is_array($payload) ? array_merge($payload, $options) : $payload,
4044
]);
4145
}
4246
}

src/Platform/Bridge/Albert/PlatformFactory.php

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter;
88
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT\ResponseConverter as GPTResponseConverter;
99
use PhpLlm\LlmChain\Platform\Contract;
10+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
1011
use PhpLlm\LlmChain\Platform\Platform;
1112
use Symfony\Component\HttpClient\EventSourceHttpClient;
1213
use Symfony\Contracts\HttpClient\HttpClientInterface;
13-
use Webmozart\Assert\Assert;
1414

1515
final class PlatformFactory
1616
{
@@ -19,17 +19,20 @@ public static function create(
1919
string $albertUrl,
2020
?HttpClientInterface $httpClient = null,
2121
): Platform {
22-
Assert::startsWith($albertUrl, 'https://', 'The Albert URL must start with "https://".');
22+
if (!str_starts_with($albertUrl, 'https://')) {
23+
throw new InvalidArgumentException('The Albert URL must start with "https://".');
24+
}
2325

2426
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
2527

2628
// The base URL should include the full path to the API endpoint
27-
// Albert API expects the URL to end with /v1/
29+
// Check if the URL already contains a version pattern (e.g., /v1, /v2, etc.)
2830
$baseUrl = rtrim($albertUrl, '/');
29-
if (!str_ends_with($baseUrl, '/v1')) {
31+
if (!preg_match('/\/v\d+$/', $baseUrl)) {
32+
// Default to v1 if no version is specified
3033
$baseUrl .= '/v1';
3134
}
32-
$baseUrl .= '/';
35+
// Don't add trailing slash here - let the model clients handle it
3336

3437
// Create Albert-specific model clients with custom base URL
3538
$gptClient = new GPTModelClient($httpClient, $apiKey, $baseUrl);
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Tests\Platform\Bridge\Albert;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\Albert\EmbeddingsModelClient;
8+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings;
9+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
10+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
11+
use PHPUnit\Framework\Attributes\CoversClass;
12+
use PHPUnit\Framework\Attributes\DataProvider;
13+
use PHPUnit\Framework\Attributes\Small;
14+
use PHPUnit\Framework\Attributes\Test;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\HttpClient\MockHttpClient;
17+
use Symfony\Component\HttpClient\Response\JsonMockResponse;
18+
19+
#[CoversClass(EmbeddingsModelClient::class)]
20+
#[Small]
21+
final class EmbeddingsModelClientTest extends TestCase
22+
{
23+
#[Test]
24+
public function constructorThrowsExceptionForEmptyApiKey(): void
25+
{
26+
$this->expectException(InvalidArgumentException::class);
27+
$this->expectExceptionMessage('The API key must not be empty.');
28+
29+
new EmbeddingsModelClient(
30+
new MockHttpClient(),
31+
'',
32+
'https://albert.example.com/'
33+
);
34+
}
35+
36+
#[Test]
37+
public function constructorThrowsExceptionForEmptyBaseUrl(): void
38+
{
39+
$this->expectException(InvalidArgumentException::class);
40+
$this->expectExceptionMessage('The base URL must not be empty.');
41+
42+
new EmbeddingsModelClient(
43+
new MockHttpClient(),
44+
'test-api-key',
45+
''
46+
);
47+
}
48+
49+
#[Test]
50+
public function supportsEmbeddingsModel(): void
51+
{
52+
$client = new EmbeddingsModelClient(
53+
new MockHttpClient(),
54+
'test-api-key',
55+
'https://albert.example.com/'
56+
);
57+
58+
$embeddingsModel = new Embeddings('text-embedding-ada-002');
59+
self::assertTrue($client->supports($embeddingsModel));
60+
}
61+
62+
#[Test]
63+
public function doesNotSupportNonEmbeddingsModel(): void
64+
{
65+
$client = new EmbeddingsModelClient(
66+
new MockHttpClient(),
67+
'test-api-key',
68+
'https://albert.example.com/'
69+
);
70+
71+
$gptModel = new GPT('gpt-3.5-turbo');
72+
self::assertFalse($client->supports($gptModel));
73+
}
74+
75+
#[Test]
76+
#[DataProvider('requestDataProvider')]
77+
public function requestSendsCorrectHttpRequest(array|string $payload, array $options, array|string $expectedJson): void
78+
{
79+
$capturedRequest = null;
80+
$httpClient = new MockHttpClient(function ($method, $url, $options) use (&$capturedRequest) {
81+
$capturedRequest = ['method' => $method, 'url' => $url, 'options' => $options];
82+
return new JsonMockResponse(['data' => []]);
83+
});
84+
85+
$client = new EmbeddingsModelClient(
86+
$httpClient,
87+
'test-api-key',
88+
'https://albert.example.com/v1/'
89+
);
90+
91+
$model = new Embeddings('text-embedding-ada-002');
92+
$response = $client->request($model, $payload, $options);
93+
94+
self::assertNotNull($capturedRequest);
95+
self::assertSame('POST', $capturedRequest['method']);
96+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedRequest['url']);
97+
self::assertArrayHasKey('normalized_headers', $capturedRequest['options']);
98+
self::assertArrayHasKey('authorization', $capturedRequest['options']['normalized_headers']);
99+
self::assertStringContainsString('Bearer test-api-key', $capturedRequest['options']['normalized_headers']['authorization'][0]);
100+
101+
// Check JSON body - it might be in 'body' after processing
102+
if (isset($capturedRequest['options']['body'])) {
103+
$actualJson = json_decode($capturedRequest['options']['body'], true);
104+
self::assertEquals($expectedJson, $actualJson);
105+
} else {
106+
self::assertSame($expectedJson, $capturedRequest['options']['json']);
107+
}
108+
}
109+
110+
public static function requestDataProvider(): \Iterator
111+
{
112+
yield 'with array payload and no options' => [
113+
'payload' => ['input' => 'test text', 'model' => 'text-embedding-ada-002'],
114+
'options' => [],
115+
'expectedJson' => ['input' => 'test text', 'model' => 'text-embedding-ada-002'],
116+
];
117+
118+
yield 'with string payload and no options' => [
119+
'payload' => 'test text',
120+
'options' => [],
121+
'expectedJson' => 'test text',
122+
];
123+
124+
yield 'with array payload and options' => [
125+
'payload' => ['input' => 'test text', 'model' => 'text-embedding-ada-002'],
126+
'options' => ['dimensions' => 1536],
127+
'expectedJson' => ['dimensions' => 1536, 'input' => 'test text', 'model' => 'text-embedding-ada-002'],
128+
];
129+
130+
yield 'options override payload values' => [
131+
'payload' => ['input' => 'test text', 'model' => 'text-embedding-ada-002'],
132+
'options' => ['model' => 'text-embedding-3-small'],
133+
'expectedJson' => ['model' => 'text-embedding-3-small', 'input' => 'test text'],
134+
];
135+
}
136+
137+
#[Test]
138+
public function requestHandlesBaseUrlWithoutTrailingSlash(): void
139+
{
140+
$capturedUrl = null;
141+
$httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) {
142+
$capturedUrl = $url;
143+
return new JsonMockResponse(['data' => []]);
144+
});
145+
146+
$client = new EmbeddingsModelClient(
147+
$httpClient,
148+
'test-api-key',
149+
'https://albert.example.com/v1'
150+
);
151+
152+
$model = new Embeddings('text-embedding-ada-002');
153+
$client->request($model, ['input' => 'test']);
154+
155+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedUrl);
156+
}
157+
158+
#[Test]
159+
public function requestHandlesBaseUrlWithTrailingSlash(): void
160+
{
161+
$capturedUrl = null;
162+
$httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) {
163+
$capturedUrl = $url;
164+
return new JsonMockResponse(['data' => []]);
165+
});
166+
167+
$client = new EmbeddingsModelClient(
168+
$httpClient,
169+
'test-api-key',
170+
'https://albert.example.com/v1/'
171+
);
172+
173+
$model = new Embeddings('text-embedding-ada-002');
174+
$client->request($model, ['input' => 'test']);
175+
176+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedUrl);
177+
}
178+
}

0 commit comments

Comments
 (0)