diff --git a/composer.json b/composer.json index 9651b02..98892f9 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "test:lint": "@php vendor/bin/pint --config https://raw.githubusercontent.com/DIJ-digital/pint-config/main/pint.json", "test:unit": "pest", "test:types": "phpstan", - "test:refactor": "rector --dry-run", + "test:refactor": "rector", "test": [ "@test:lint", "@test:type-coverage", diff --git a/src/Contracts/TransporterInterface.php b/src/Contracts/TransporterInterface.php index 8234ade..558497a 100644 --- a/src/Contracts/TransporterInterface.php +++ b/src/Contracts/TransporterInterface.php @@ -9,34 +9,34 @@ interface TransporterInterface { /** - * @param array $options + * @param array $options */ public function request(string $method, string $uri, array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function get(string $uri, array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function post(string $uri, array $options = []): ResponseInterface; /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options */ public function postJson(string $uri, array $data = [], array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function delete(string $uri, array $options = []): ResponseInterface; /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options */ public function patchJson(string $uri, array $data = [], array $options = []): ResponseInterface; } diff --git a/src/Langfuse.php b/src/Langfuse.php index ebe96f9..06ee98b 100644 --- a/src/Langfuse.php +++ b/src/Langfuse.php @@ -12,8 +12,7 @@ public function __construct( private readonly TransporterInterface $transporter, private readonly string $environment = 'default', private readonly string $label = 'latest', - ) { - } + ) {} public function prompt(): Prompt { @@ -37,4 +36,11 @@ public function score(): Score transporter: $this->transporter, ); } + + public function model(): Model + { + return new Model( + transporter: $this->transporter, + ); + } } diff --git a/src/Model.php b/src/Model.php new file mode 100644 index 0000000..a14aef5 --- /dev/null +++ b/src/Model.php @@ -0,0 +1,138 @@ +transporter->get( + uri: sprintf('/api/public/models/%s', urlencode($modelId)), + ); + + /** @var array{ + * id: string, + * modelName: string, + * matchPattern: string, + * startDate: string|null, + * unit: string|null, + * inputPrice: float|null, + * outputPrice: float|null, + * totalPrice: float|null, + * tokenizerId: string|null, + * tokenizerConfig: mixed, + * isLangfuseManaged: bool, + * createdAt: string + * } $data + */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return ModelResponse::fromArray($data); + } + + /** + * @throws JsonException + */ + public function list( + ?int $page = null, + ?int $limit = null, + ): ModelListResponse { + $response = $this->transporter->get( + uri: '/api/public/models', + options: ['query' => array_filter([ + 'page' => $page, + 'limit' => $limit, + ])] + ); + + /** @var array{ + * data: array, + * meta: array{page: int, limit: int, totalPages: int, totalItems: int} + * } $data + */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return ModelListResponse::fromArray($data); + } + + /** + * @throws JsonException + */ + public function create( + string $modelName, + string $matchPattern, + ?string $startDate = null, + ?string $unit = null, + ?float $inputPrice = null, + ?float $outputPrice = null, + ?float $totalPrice = null, + ?string $tokenizerId = null, + mixed $tokenizerConfig = null, + ): ModelResponse { + $response = $this->transporter->postJson( + uri: '/api/public/models', + data: array_filter([ + 'modelName' => $modelName, + 'matchPattern' => $matchPattern, + 'startDate' => $startDate, + 'unit' => $unit, + 'inputPrice' => $inputPrice, + 'outputPrice' => $outputPrice, + 'totalPrice' => $totalPrice, + 'tokenizerId' => $tokenizerId, + 'tokenizerConfig' => $tokenizerConfig, + ], fn ($v) => $v !== null), + ); + + /** @var array{ + * id: string, + * modelName: string, + * matchPattern: string, + * startDate: string|null, + * unit: string|null, + * inputPrice: float|null, + * outputPrice: float|null, + * totalPrice: float|null, + * tokenizerId: string|null, + * tokenizerConfig: mixed, + * isLangfuseManaged: bool, + * createdAt: string + * } $data + */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return ModelResponse::fromArray($data); + } + + public function delete(string $modelId): void + { + $this->transporter->delete( + uri: sprintf('/api/public/models/%s', urlencode($modelId)), + ); + } +} diff --git a/src/Responses/ModelListResponse.php b/src/Responses/ModelListResponse.php new file mode 100644 index 0000000..8343e9b --- /dev/null +++ b/src/Responses/ModelListResponse.php @@ -0,0 +1,48 @@ + $data + */ + public function __construct( + public array $data, + public MetaData $meta, + ) {} + + /** + * @param array{ + * data: array, + * meta: array{page: int, limit: int, totalPages: int, totalItems: int} + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + data: array_map( + fn (array $item): ModelResponse => ModelResponse::fromArray($item), + $data['data'] + ), + meta: MetaData::fromArray($data['meta']), + ); + } +} diff --git a/src/Responses/ModelResponse.php b/src/Responses/ModelResponse.php new file mode 100644 index 0000000..51d9595 --- /dev/null +++ b/src/Responses/ModelResponse.php @@ -0,0 +1,57 @@ +|string> $headers + */ + public function __construct(int $status = 204, array $headers = [], string $version = '1.1', ?string $reason = null) + { + parent::__construct($status, $headers, '', $version, $reason); + } +} diff --git a/src/Testing/Responses/GetModelListResponse.php b/src/Testing/Responses/GetModelListResponse.php new file mode 100644 index 0000000..d823ad9 --- /dev/null +++ b/src/Testing/Responses/GetModelListResponse.php @@ -0,0 +1,63 @@ +|string> $headers + */ + public function __construct(int $status = 200, array $headers = [], string $version = '1.1', ?string $reason = null) + { + parent::__construct($status, $headers, (string) json_encode($this->payload()), $version, $reason); + } + + /** + * @return array + */ + public function payload(): array + { + return [ + 'data' => [ + [ + 'id' => 'model-abc123', + 'modelName' => 'gpt-4-turbo', + 'matchPattern' => '(?i)^gpt-4-turbo$', + 'startDate' => '2024-01-01T00:00:00.000Z', + 'unit' => 'TOKENS', + 'inputPrice' => 0.00001, + 'outputPrice' => 0.00003, + 'totalPrice' => null, + 'tokenizerId' => 'openai', + 'tokenizerConfig' => null, + 'isLangfuseManaged' => false, + 'createdAt' => '2025-01-22T10:00:00.000Z', + ], + [ + 'id' => 'model-def456', + 'modelName' => 'claude-3-opus', + 'matchPattern' => '(?i)^claude-3-opus$', + 'startDate' => null, + 'unit' => 'TOKENS', + 'inputPrice' => 0.000015, + 'outputPrice' => 0.000075, + 'totalPrice' => null, + 'tokenizerId' => null, + 'tokenizerConfig' => null, + 'isLangfuseManaged' => true, + 'createdAt' => '2025-01-22T09:00:00.000Z', + ], + ], + 'meta' => [ + 'page' => 1, + 'limit' => 10, + 'totalPages' => 1, + 'totalItems' => 2, + ], + ]; + } +} diff --git a/src/Testing/Responses/GetModelResponse.php b/src/Testing/Responses/GetModelResponse.php new file mode 100644 index 0000000..befb5cd --- /dev/null +++ b/src/Testing/Responses/GetModelResponse.php @@ -0,0 +1,41 @@ +|string> $headers + * @param array $data + */ + public function __construct(int $status = 200, array $headers = [], string $version = '1.1', ?string $reason = null, array $data = []) + { + parent::__construct($status, $headers, (string) json_encode($this->payload($data)), $version, $reason); + } + + /** + * @param array $data + * @return array + */ + public function payload(array $data = []): array + { + return array_merge([ + 'id' => 'model-abc123', + 'modelName' => 'gpt-4-turbo', + 'matchPattern' => '(?i)^gpt-4-turbo$', + 'startDate' => '2024-01-01T00:00:00.000Z', + 'unit' => 'TOKENS', + 'inputPrice' => 0.00001, + 'outputPrice' => 0.00003, + 'totalPrice' => null, + 'tokenizerId' => 'openai', + 'tokenizerConfig' => null, + 'isLangfuseManaged' => false, + 'createdAt' => '2025-01-22T10:00:00.000Z', + ], $data); + } +} diff --git a/src/Testing/Responses/PostModelResponse.php b/src/Testing/Responses/PostModelResponse.php new file mode 100644 index 0000000..dbb793d --- /dev/null +++ b/src/Testing/Responses/PostModelResponse.php @@ -0,0 +1,41 @@ +|string> $headers + * @param array $data + */ + public function __construct(int $status = 200, array $headers = [], string $version = '1.1', ?string $reason = null, array $data = []) + { + parent::__construct($status, $headers, (string) json_encode($this->payload($data)), $version, $reason); + } + + /** + * @param array $data + * @return array + */ + public function payload(array $data = []): array + { + return array_merge([ + 'id' => 'model-new123', + 'modelName' => 'custom-model', + 'matchPattern' => '(?i)^custom-model$', + 'startDate' => null, + 'unit' => 'TOKENS', + 'inputPrice' => 0.00002, + 'outputPrice' => 0.00004, + 'totalPrice' => null, + 'tokenizerId' => null, + 'tokenizerConfig' => null, + 'isLangfuseManaged' => false, + 'createdAt' => '2025-01-22T11:00:00.000Z', + ], $data); + } +} diff --git a/src/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index 319cf49..a2b734b 100644 --- a/src/Transporters/HttpTransporter.php +++ b/src/Transporters/HttpTransporter.php @@ -21,8 +21,7 @@ class HttpTransporter implements TransporterInterface { public function __construct( public readonly ClientInterface $client - ) { - } + ) {} /** * @throws BadRequestException @@ -45,7 +44,7 @@ public function request(string $method, string $uri, array $options = []): Respo } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -61,7 +60,7 @@ public function get(string $uri, array $options = []): ResponseInterface } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -82,7 +81,7 @@ public function postJson(string $uri, array $data = [], array $options = []): Re } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -98,8 +97,8 @@ public function delete(string $uri, array $options = []): ResponseInterface } /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options * * @throws BadRequestException * @throws ForbiddenException diff --git a/tests/Feature/ModelTest.php b/tests/Feature/ModelTest.php new file mode 100644 index 0000000..612ceeb --- /dev/null +++ b/tests/Feature/ModelTest.php @@ -0,0 +1,101 @@ + $handlerStack]); + + $model = (new Langfuse(new HttpTransporter($client))) + ->model() + ->get('model-abc123'); + + expect($model)->toBeInstanceOf(ModelResponse::class) + ->and($model->id)->toBe('model-abc123') + ->and($model->modelName)->toBe('gpt-4-turbo') + ->and($model->matchPattern)->toBe('(?i)^gpt-4-turbo$') + ->and($model->unit)->toBe('TOKENS') + ->and($model->inputPrice)->toBe(0.00001) + ->and($model->outputPrice)->toBe(0.00003) + ->and($model->isLangfuseManaged)->toBe(false); +}); + +it('can list models', function (): void { + $mock = new MockHandler([ + new GetModelListResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $models = (new Langfuse(new HttpTransporter($client))) + ->model() + ->list(); + + expect($models)->toBeInstanceOf(ModelListResponse::class) + ->and($models->data)->toBeArray() + ->and($models->data)->toHaveCount(2) + ->and($models->data[0])->toBeInstanceOf(ModelResponse::class) + ->and($models->data[0]->modelName)->toBe('gpt-4-turbo') + ->and($models->data[0]->isLangfuseManaged)->toBe(false) + ->and($models->data[1]->modelName)->toBe('claude-3-opus') + ->and($models->data[1]->isLangfuseManaged)->toBe(true) + ->and($models->meta)->toBeInstanceOf(MetaData::class) + ->and($models->meta->totalItems)->toBe(2); +}); + +it('can create a model', function (): void { + $mock = new MockHandler([ + new PostModelResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $model = (new Langfuse(new HttpTransporter($client))) + ->model() + ->create( + modelName: 'custom-model', + matchPattern: '(?i)^custom-model$', + unit: 'TOKENS', + inputPrice: 0.00002, + outputPrice: 0.00004, + ); + + expect($model)->toBeInstanceOf(ModelResponse::class) + ->and($model->id)->toBe('model-new123') + ->and($model->modelName)->toBe('custom-model') + ->and($model->matchPattern)->toBe('(?i)^custom-model$') + ->and($model->isLangfuseManaged)->toBe(false); +}); + +it('can delete a model', function (): void { + $mock = new MockHandler([ + new DeleteModelResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $langfuse = new Langfuse(new HttpTransporter($client)); + $langfuse->model()->delete('model-abc123'); + + expect($mock->count())->toBe(0); +});