Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to this project will be documented in this file.

## [1.2.0] - 2026-07-21

### Changed

- **Breaking:** `embed()` and `batchEmbed()` now return the full `EmbeddingResponse` instead of bare vectors. Get the vectors via `output()` (one per input, so `output()[0]` for `embed()`), and token usage via `usage()` - previously usage on embedding calls was unrecoverable.
- **Breaking:** `rerank()` now returns the full `RerankResponse` instead of a `RerankResult[]` array. Get the sorted results via `output()` (or the `$results` property), and token usage via `usage()`.
- **Breaking:** `EmbeddingResponse::output()` now returns validated float vectors and throws `SthAI\Exception\ResponseException` for non-float encoding formats or when the response carries no embeddings. The raw entries stay available on the `$data` property.

## [1.1.1] - 2026-07-20

### Added
Expand Down Expand Up @@ -45,6 +53,7 @@ Initial release: a PHP port of [sthai-py](https://github.com/sitehostnz/sthai-py
- `models()`, `healthy()`, session pinning (explicit or auto-generated) and `SthAI\Image` data-URI helpers
- Offline test suite replaying fixtures captured from the live API, shared with sthai-py

[1.2.0]: https://github.com/sitehostnz/sthai-php/compare/v1.1.1...v1.2.0
[1.1.1]: https://github.com/sitehostnz/sthai-php/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/sitehostnz/sthai-php/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/sitehostnz/sthai-php/compare/v0.1.0...v1.0.0
Expand Down
31 changes: 20 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,29 +110,29 @@ The client does not re-validate the result against the schema. If the output is

### Embeddings

`embed()` turns one input - text, images, or both - into a single vector. The embedding model is instruction-trained: document embedding is the default, and `query: true` switches to the query instruction for search-style lookups. `dimensions` truncates the vector server-side (Matryoshka - powers of two work best):
`embed()` turns one input - text, images, or both - into a single vector, the sole entry in the response's `output()`. The embedding model is instruction-trained: document embedding is the default, and `query: true` switches to the query instruction for search-style lookups. `dimensions` truncates the vector server-side (Matryoshka - powers of two work best):

```php
$vector = $client->embed("The Beehive is New Zealand's parliament building.");
$queryVector = $client->embed('Where does NZ parliament sit?', query: true);
$small = $client->embed('Compact vector, please.', dimensions: 512);
$vector = $client->embed("The Beehive is New Zealand's parliament building.")->output()[0];
$queryVector = $client->embed('Where does NZ parliament sit?', query: true)->output()[0];
$small = $client->embed('Compact vector, please.', dimensions: 512)->output()[0];
```

`batchEmbed()` embeds many texts in one request, returning one vector per text in order:
`batchEmbed()` embeds many texts in one request; the response's `output()` is one vector per text in order:

```php
$vectors = $client->batchEmbed([
'Wellington is the capital of New Zealand.',
'Auckland is the largest city in New Zealand.',
]);
])->output();
```

### Reranking

`rerank()` scores each document against a query and returns results sorted by relevance, with each result's `index` mapping back to your input list:
`rerank()` scores each document against a query; `output()` on the response is the results sorted by relevance, with each result's `index` mapping back to your input list:

```php
$results = $client->rerank(
$response = $client->rerank(
'What is the capital of New Zealand?',
[
'The capital of New Zealand is Wellington.',
Expand All @@ -141,7 +141,7 @@ $results = $client->rerank(
],
topN: 2,
);
foreach ($results as $result) {
foreach ($response->output() as $result) {
echo $result->relevanceScore, ' ', $result->document->text, PHP_EOL;
}
```
Expand All @@ -150,12 +150,21 @@ Pass `instruction:` to steer relevance for a specific task; the model applies a

### Response helpers

Every response type has `usage()` (input/output/cached token counts) and `output()` (the useful payload), and `toArray()` exposes the complete decoded payload. The full response from the most recent inference call is available via `lastResponse()`:
Every inference, embedding and rerank call returns the full response object, and every response object has `usage()` (input/output/cached token counts) and `output()` (the useful payload), with `toArray()` exposing the complete decoded payload:

```php
$response = $client->chat('Hello!');
echo $response->usage()->inputTokens, ' ', $response->usage()->outputTokens;
echo $client->lastResponse()->model;

$embedded = $client->embed('Hello!');
echo $embedded->usage()->inputTokens, ' ', count($embedded->output()[0]);
```

The one exception is `structuredResponse()`, which returns the decoded array directly; the full response from the most recent inference call remains available via `lastResponse()`:

```php
$city = $client->structuredResponse('Describe Wellington.', $citySchema);
echo $client->lastResponse()->usage()->inputTokens;
```

### Sessions
Expand Down
68 changes: 18 additions & 50 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,9 @@ private function parseStructured(InferenceResponse $response): array
}

/**
* Embed a single input (text, images, or both) and return its vector.
* Embed a single input (text, images, or both) and return the full
* embedding response; output()[0] is the resulting vector, and
* usage() reports the token counts.
*
* The instruction-trained model is steered by a default instruction
* from EmbeddingParams: the model's document instruction, or its query
Expand All @@ -481,8 +483,6 @@ private function parseStructured(InferenceResponse $response): array
*
* @param string[] $imageUrls image URLs (or data URIs, e.g. from Image::dataUriFromBytes())
* @param string[] $imageFiles paths of local image files, inlined as data URIs
*
* @return array<int, float|int> the embedding vector
*/
public function embed(
?string $text = null,
Expand All @@ -492,7 +492,7 @@ public function embed(
array $imageUrls = [],
array $imageFiles = [],
?int $dimensions = null
): array {
): EmbeddingResponse {
$imageParts = self::buildImageParts($imageUrls, $imageFiles);
$parts = [];
// An empty string is treated as no text: embedding it would produce
Expand Down Expand Up @@ -521,7 +521,7 @@ public function embed(
// trained to embed
$messages[] = ['role' => 'assistant', 'content' => ''];

$decoded = $this->embeddingRequest([
return $this->embeddingRequest([
'messages' => $messages,
'model' => $model,
'encoding_format' => 'float',
Expand All @@ -531,17 +531,13 @@ public function embed(
// matches batchEmbed's plain-input form, which defaults to true
'add_special_tokens' => true,
]);
$outputs = $decoded->output();
if ($outputs === []) {
throw new ResponseException('server returned no embedding data');
}

return self::floatEmbedding($outputs[0]);
}

/**
* Embed a batch of texts in one request, returning one vector per text
* in the same order. Text-only; use embed() for multimodal input.
* Embed a batch of texts in one request and return the full embedding
* response; output() is one vector per text in the same order, and
* usage() reports the token counts. Text-only; use embed() for
* multimodal input.
*
* Only the plain-input request form batches, and it bypasses the
* server-side chat template, so each text is rendered through a local
Expand All @@ -553,8 +549,6 @@ public function embed(
* untemplated input. dimensions truncates the vectors server-side.
*
* @param string[] $texts
*
* @return array<int, array<int, float|int>> one vector per text, in input order
*/
public function batchEmbed(
array $texts,
Expand All @@ -563,7 +557,7 @@ public function batchEmbed(
?string $instruction = null,
?string $template = null,
?int $dimensions = null
): array {
): EmbeddingResponse {
if ($texts === []) {
throw new InputException('batchEmbed() requires at least one text');
}
Expand Down Expand Up @@ -607,19 +601,12 @@ public function batchEmbed(
$inputs[] = Template::render($template, $instruction, $text);
}

$decoded = $this->embeddingRequest([
return $this->embeddingRequest([
'input' => $inputs,
'model' => $model,
'encoding_format' => 'float',
'dimensions' => $dimensions,
]);

$vectors = [];
foreach ($decoded->output() as $embedding) {
$vectors[] = self::floatEmbedding($embedding);
}

return $vectors;
}

/**
Expand Down Expand Up @@ -687,23 +674,6 @@ private static function checkDimensions(string $model, ?int $dimensions): void
}
}

/**
* Ensure a decoded embedding is the float list the client requested
* (the server returns strings for non-float encoding formats).
*
* @param array<int, float|int>|string $embedding
*
* @return array<int, float|int>
*/
private static function floatEmbedding($embedding): array
{
if (is_string($embedding)) {
throw new ResponseException('expected a float embedding, got an encoded string');
}

return $embedding;
}

/**
* POST an inference request and record the decoded last response.
*
Expand Down Expand Up @@ -786,9 +756,11 @@ public function models(): array
}

/**
* Score each document against the query and return the results sorted
* by relevance score descending, each carrying the document, its
* relevanceScore, and its index in the input documents list.
* Score each document against the query and return the full rerank
* response. Its output() method is the results sorted by relevance
* score descending - each carrying the document, its relevanceScore,
* and its index in the input documents list - and usage() reports the
* token counts.
*
* All documents are returned unless topN limits it. The
* instruction-trained model applies its own default instruction; pass
Expand All @@ -799,32 +771,28 @@ public function models(): array
*
* @param string|array<string, mixed> $query
* @param array<int, string|array<string, mixed>> $documents
*
* @return \SthAI\Response\RerankResult[]
*/
public function rerank(
$query,
array $documents,
string $model = RerankingModel::QWEN_3_VL_8B,
?int $topN = null,
?string $instruction = null
): array {
): RerankResponse {
if ($documents === []) {
throw new InputException('rerank() requires at least one document');
}
if ($topN !== null && $topN < 1) {
throw new InputException('topN must be a positive integer');
}

$decoded = RerankResponse::fromArray($this->requestJson('POST', self::RERANKING_ENDPOINT, [
return RerankResponse::fromArray($this->requestJson('POST', self::RERANKING_ENDPOINT, [
'query' => $query,
'documents' => array_values($documents),
'top_n' => $topN,
'model' => $model,
'instruction' => $instruction,
]));

return $decoded->results;
}

/**
Expand Down
15 changes: 13 additions & 2 deletions src/Response/EmbeddingResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace SthAI\Response;

use SthAI\Exception\ResponseException;

/**
* The full response from an embedding request.
*/
Expand Down Expand Up @@ -60,19 +62,28 @@ public function usage(): Usage
}

/**
* The embeddings in input order (base64/binary formats are strings).
* The embeddings in input order as float vectors. Throws
* ResponseException when the response carries no embeddings, or for
* non-float encoding formats (where the server returns strings; the
* raw entries stay available on data).
*
* @return array<int, array<int, float|int>|string>
* @return array<int, array<int, float|int>>
*/
public function output(): array
{
if ($this->data === []) {
throw new ResponseException('server returned no embedding data');
}
$sorted = $this->data;
usort($sorted, static function (EmbeddingData $a, EmbeddingData $b): int {
return $a->index <=> $b->index;
});

$embeddings = [];
foreach ($sorted as $entry) {
if (is_string($entry->embedding)) {
throw new ResponseException('expected a float embedding, got an encoded string');
}
$embeddings[] = $entry->embedding;
}

Expand Down
12 changes: 10 additions & 2 deletions tests/BatchEmbedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,30 @@ public function testOneVectorPerTextInOrder(): void
null,
null,
32
);
)->output();
$expected = array_column($fixture['response']['data'], 'embedding');
$this->assertSame($expected, $vectors);
foreach ($vectors as $vector) {
$this->assertCount(32, $vector);
}
}

public function testUsageReportsTokenCounts(): void
{
$fixture = $this->transport->register('batch_embed');
$usage = $this->client()->batchEmbed(self::TEXTS)->usage();
$this->assertSame($fixture['response']['usage']['prompt_tokens'], $usage->inputTokens);
$this->assertSame(0, $usage->outputTokens);
}

public function testOutOfOrderResponseResortedByIndex(): void
{
$fixture = Fixtures::load('batch_embed');
$byIndex = array_column($fixture['response']['data'], null, 'index');
$fixture['response']['data'] = [$byIndex[2], $byIndex[0], $byIndex[1]];
$this->transport->respond('POST', '/v1/embeddings', $fixture['response']);

$vectors = $this->client()->batchEmbed(self::TEXTS);
$vectors = $this->client()->batchEmbed(self::TEXTS)->output();
$this->assertSame(
[$byIndex[0]['embedding'], $byIndex[1]['embedding'], $byIndex[2]['embedding']],
$vectors
Expand Down
14 changes: 11 additions & 3 deletions tests/EmbedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,20 @@ public function testReturnsFloatVector(): void
[],
[],
32
);
)->output()[0];
$this->assertSame($fixture['response']['data'][0]['embedding'], $vector);
$this->assertCount(32, $vector);
$this->assertContainsOnly('float', $vector);
}

public function testUsageReportsTokenCounts(): void
{
$fixture = $this->transport->register('embed_single');
$usage = $this->client()->embed('The Beehive.')->usage();
$this->assertSame($fixture['response']['usage']['prompt_tokens'], $usage->inputTokens);
$this->assertSame(0, $usage->outputTokens);
}

public function testChatFormRequestShape(): void
{
$this->transport->register('embed_single');
Expand Down Expand Up @@ -99,7 +107,7 @@ public function testMultimodalContentParts(): void
false,
null,
[Image::dataUriFromBytes(self::FAKE_PNG)]
);
)->output()[0];
$this->assertNotEmpty($vector);
$body = $this->transport->lastCall()->body;
$this->assertNotNull($body);
Expand Down Expand Up @@ -160,6 +168,6 @@ public function testEmptyResponseDataRaises(): void
$this->transport->respond('POST', '/v1/embeddings', $fixture['response']);
$this->expectException(ResponseException::class);
$this->expectExceptionMessage('no embedding data');
$this->client()->embed('text');
$this->client()->embed('text')->output();
}
}
Loading