Skip to content

Commit d7f8bba

Browse files
committed
feat: Add XAI model provider
1 parent 8585916 commit d7f8bba

4 files changed

Lines changed: 305 additions & 2 deletions

File tree

src/Data/ModelInfo.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
readonly class ModelInfo
1515
{
1616
/**
17+
* @param ?float $inputCostPerToken The input cost per token in USD
18+
* @param ?float $outputCostPerToken The output cost per token in USD
1719
* @param array<\Cortex\ModelInfo\Enums\ModelFeature> $features
1820
* @param array<string, mixed> $metadata
1921
*/

src/Providers/LiteLLMModelInfoProvider.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ class LiteLLMModelInfoProvider implements ModelInfoProvider
2424

2525
protected const string LITELLM_STATIC_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
2626

27-
protected ClientInterface $httpClient;
28-
2927
public function __construct(
3028
protected ?string $host = null,
3129
#[SensitiveParameter]
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cortex\ModelInfo\Providers;
6+
7+
use SensitiveParameter;
8+
use Cortex\ModelInfo\Data\ModelInfo;
9+
use Psr\Http\Client\ClientInterface;
10+
use Cortex\ModelInfo\Enums\ModelType;
11+
use Cortex\ModelInfo\Enums\ModelFeature;
12+
use Cortex\ModelInfo\Enums\ModelProvider;
13+
use Cortex\ModelInfo\Contracts\ModelInfoProvider;
14+
use Cortex\ModelInfo\Providers\Concerns\ChecksSupport;
15+
use Cortex\ModelInfo\Providers\Concerns\MakesRequests;
16+
17+
/**
18+
* @phpstan-type XAILanguageModelResponse array{id: string, fingerprint: string, created: int, object: string, owned_by: string, version: string, input_modalities: array<array-key, string>, output_modalities: array<array-key, string>, prompt_text_token_price: int, cached_prompt_text_token_price: int, prompt_image_token_price: int, completion_text_token_price: int, aliases: array<array-key, string>}
19+
*/
20+
class XAIModelInfoProvider implements ModelInfoProvider
21+
{
22+
use ChecksSupport;
23+
use MakesRequests;
24+
25+
public function __construct(
26+
#[SensitiveParameter]
27+
protected ?string $apiKey = null,
28+
?ClientInterface $httpClient = null,
29+
) {
30+
$this->httpClient = $httpClient ?? self::discoverHttpClientOrFail();
31+
}
32+
33+
public function supportedModelProviders(): array
34+
{
35+
return [
36+
ModelProvider::XAI,
37+
];
38+
}
39+
40+
/**
41+
* @throws \Cortex\ModelInfo\Exceptions\ModelInfoException
42+
*
43+
* @return array<array-key, \Cortex\ModelInfo\Data\ModelInfo>
44+
*/
45+
public function getModels(ModelProvider $modelProvider): array
46+
{
47+
$this->checkSupportOrFail($modelProvider);
48+
49+
// Only supports chat models at the moment
50+
$body = $this->getLanguageModelsResponse();
51+
52+
return array_values(array_map(
53+
fn(array $model): ModelInfo => self::mapModelInfo($model),
54+
$body['models'],
55+
));
56+
}
57+
58+
/**
59+
* @throws \Cortex\ModelInfo\Exceptions\ModelInfoException
60+
*/
61+
public function getModelInfo(ModelProvider $modelProvider, string $model): ModelInfo
62+
{
63+
$this->checkSupportOrFail($modelProvider);
64+
65+
return self::mapModelInfo(
66+
$this->getLanguageModelResponse($model),
67+
);
68+
}
69+
70+
/**
71+
* @param XAILanguageModelResponse $body
72+
*/
73+
protected static function mapModelInfo(array $body): ModelInfo
74+
{
75+
return new ModelInfo(
76+
name: $body['id'],
77+
provider: ModelProvider::XAI,
78+
type: self::getModelType($body),
79+
maxInputTokens: self::getMaxInputTokens($body),
80+
maxOutputTokens: null,
81+
inputCostPerToken: self::getCostPerToken($body['prompt_text_token_price']),
82+
outputCostPerToken: self::getCostPerToken($body['completion_text_token_price']),
83+
features: self::getFeatures($body),
84+
);
85+
}
86+
87+
/**
88+
* @param XAILanguageModelResponse $body
89+
*
90+
* @return array<int, \Cortex\ModelInfo\Enums\ModelFeature>
91+
*/
92+
protected static function getFeatures(array $body): array
93+
{
94+
$features = [
95+
ModelFeature::JsonOutput,
96+
ModelFeature::ToolCalling,
97+
ModelFeature::ToolChoice,
98+
ModelFeature::StructuredOutput,
99+
];
100+
101+
if (in_array('image', $body['input_modalities'], true)) {
102+
$features[] = ModelFeature::Vision;
103+
}
104+
105+
return $features;
106+
}
107+
108+
/**
109+
* @param XAILanguageModelResponse $body
110+
*/
111+
protected static function getModelType(array $body): ModelType
112+
{
113+
if (in_array('text', $body['input_modalities'], true)) {
114+
return ModelType::Chat;
115+
}
116+
117+
return ModelType::Unknown;
118+
}
119+
120+
protected static function getCostPerToken(int $pricePerMillionTokens): float
121+
{
122+
// Convert from cents per million tokens to dollars per token
123+
return ($pricePerMillionTokens / 100.0) / 1_000_000;
124+
}
125+
126+
/**
127+
* @param XAILanguageModelResponse $body
128+
*/
129+
protected static function getMaxInputTokens(array $body): ?int
130+
{
131+
return str_starts_with($body['id'], 'grok-2-vision')
132+
? 32768
133+
: 131072;
134+
}
135+
136+
/**
137+
* @throws \Cortex\ModelInfo\Exceptions\ModelInfoException
138+
*
139+
* @return array{models: array<array-key, XAILanguageModelResponse>}
140+
*/
141+
protected function getLanguageModelsResponse(): array
142+
{
143+
$request = self::discoverHttpRequestFactoryOrFail()
144+
->createRequest('GET', 'https://api.x.ai/v1/language-models')
145+
->withHeader('Authorization', 'Bearer ' . $this->apiKey);
146+
147+
// @phpstan-ignore return.type
148+
return $this->getJsonResponse($request);
149+
}
150+
151+
/**
152+
* @throws \Cortex\ModelInfo\Exceptions\ModelInfoException
153+
*
154+
* @return XAILanguageModelResponse
155+
*/
156+
protected function getLanguageModelResponse(string $id): array
157+
{
158+
$request = self::discoverHttpRequestFactoryOrFail()
159+
->createRequest('GET', 'https://api.x.ai/v1/language-models/' . $id)
160+
->withHeader('Authorization', 'Bearer ' . $this->apiKey);
161+
162+
// @phpstan-ignore return.type
163+
return $this->getJsonResponse($request);
164+
}
165+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cortex\ModelInfo\Tests\Unit\Providers;
6+
7+
use GuzzleHttp\Psr7\Response;
8+
use Cortex\ModelInfo\Data\ModelInfo;
9+
use Cortex\ModelInfo\Enums\ModelType;
10+
use Cortex\ModelInfo\Enums\ModelFeature;
11+
use Cortex\ModelInfo\Enums\ModelProvider;
12+
use Cortex\ModelInfo\Exceptions\ModelInfoException;
13+
use Cortex\ModelInfo\Providers\XAIModelInfoProvider;
14+
15+
covers(XAIModelInfoProvider::class);
16+
17+
test('it can get the models', function (): void {
18+
$client = $this->mockHttpClient(
19+
new Response(body: json_encode([
20+
'models' => [
21+
[
22+
'id' => 'grok-3-beta',
23+
'fingerprint' => 'fp_fcf5abc12d',
24+
'created' => 1743724800,
25+
'object' => 'model',
26+
'owned_by' => 'xai',
27+
'version' => '1.0.0',
28+
'input_modalities' => [
29+
'text',
30+
],
31+
'output_modalities' => [
32+
'text',
33+
],
34+
'prompt_text_token_price' => 20000,
35+
'cached_prompt_text_token_price' => 0,
36+
'prompt_image_token_price' => 0,
37+
'completion_text_token_price' => 100000,
38+
'aliases' => [
39+
'grok-3',
40+
'grok-3-latest',
41+
],
42+
],
43+
],
44+
])),
45+
);
46+
47+
$provider = new XAIModelInfoProvider(
48+
httpClient: $client,
49+
);
50+
51+
$models = $provider->getModels(ModelProvider::XAI);
52+
53+
expect($models)->toBeArray()
54+
->toHaveCount(1)
55+
->toContainOnlyInstancesOf(ModelInfo::class);
56+
57+
$modelInfo = $models[0];
58+
59+
expect($modelInfo->name)->toBe('grok-3-beta');
60+
expect($modelInfo->provider)->toBe(ModelProvider::XAI);
61+
expect($modelInfo->type)->toBe(ModelType::Chat);
62+
expect($modelInfo->maxInputTokens)->toBe(131072);
63+
expect($modelInfo->maxOutputTokens)->toBeNull();
64+
expect($modelInfo->inputCostPerToken)->toBe(0.0002);
65+
expect($modelInfo->outputCostPerToken)->toBe(0.001);
66+
expect($modelInfo->features)
67+
->toContainOnlyInstancesOf(ModelFeature::class)
68+
->toContain(ModelFeature::JsonOutput)
69+
->toContain(ModelFeature::StructuredOutput)
70+
->toContain(ModelFeature::ToolCalling)
71+
->toContain(ModelFeature::ToolChoice);
72+
});
73+
74+
test('it can get the model info', function (): void {
75+
$client = $this->mockHttpClient(
76+
new Response(body: json_encode([
77+
'id' => 'grok-2-vision-1212',
78+
'fingerprint' => 'fp_daba7546e5',
79+
'created' => 1733961600,
80+
'object' => 'model',
81+
'owned_by' => 'xai',
82+
'version' => '0.1.0',
83+
'input_modalities' => [
84+
'text',
85+
'image',
86+
],
87+
'output_modalities' => [
88+
'text',
89+
],
90+
'prompt_text_token_price' => 20000,
91+
'prompt_image_token_price' => 20000,
92+
'completion_text_token_price' => 100000,
93+
'aliases' => [],
94+
])),
95+
);
96+
97+
$provider = new XAIModelInfoProvider(
98+
httpClient: $client,
99+
);
100+
101+
$modelInfo = $provider->getModelInfo(ModelProvider::XAI, 'grok-2-vision-1212');
102+
103+
expect($modelInfo)->toBeInstanceOf(ModelInfo::class);
104+
expect($modelInfo->name)->toBe('grok-2-vision-1212');
105+
expect($modelInfo->provider)->toBe(ModelProvider::XAI);
106+
expect($modelInfo->type)->toBe(ModelType::Chat);
107+
expect($modelInfo->maxInputTokens)->toBe(32768);
108+
expect($modelInfo->maxOutputTokens)->toBeNull();
109+
expect($modelInfo->inputCostPerToken)->toBe(0.0002);
110+
expect($modelInfo->outputCostPerToken)->toBe(0.001);
111+
expect($modelInfo->features)
112+
->toContainOnlyInstancesOf(ModelFeature::class)
113+
->toContain(ModelFeature::JsonOutput)
114+
->toContain(ModelFeature::StructuredOutput)
115+
->toContain(ModelFeature::ToolCalling)
116+
->toContain(ModelFeature::ToolChoice)
117+
->toContain(ModelFeature::Vision);
118+
});
119+
120+
it('throws an exception if the model is not found', function (): void {
121+
$client = $this->mockHttpClient(
122+
new Response(404),
123+
);
124+
125+
$provider = new XAIModelInfoProvider(
126+
httpClient: $client,
127+
);
128+
129+
expect(fn(): ModelInfo => $provider->getModelInfo(ModelProvider::XAI, 'grok-2-vision-1212'))
130+
->toThrow(ModelInfoException::class, 'Failed to get model info');
131+
});
132+
133+
it('throws an exception if the model provider is not supported', function (): void {
134+
$provider = new XAIModelInfoProvider();
135+
136+
expect(fn(): ModelInfo => $provider->getModelInfo(ModelProvider::Ollama, 'mistral-small3.1'))
137+
->toThrow(ModelInfoException::class, 'Model provider not supported');
138+
});

0 commit comments

Comments
 (0)