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
7 changes: 6 additions & 1 deletion src/Agent.php
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,13 @@ private function getProviderOptions(): array
}

if (!empty($this->tools)) {
// Pass a neutral tool definition; each provider formats it to its own wire shape.
$options['tools'] = array_values(array_map(
fn (ToolInterface $tool) => $tool->toAnthropic(),
fn (ToolInterface $tool) => [
'name' => $tool->getName(),
'description' => $tool->getDescription(),
'parameters' => $tool->getParameterSchema(),
],
$this->tools
));
}
Expand Down
18 changes: 2 additions & 16 deletions src/Contracts/ToolInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
* Contract for tools that can be invoked by an LLM agent.
*
* Tools expose a name, description, and parameter schema so the LLM can decide
* when and how to call them. Implementations must be serialisable to both
* Anthropic and OpenAI API formats.
* when and how to call them. Each provider translates that neutral definition
* into its own wire format.
*/
interface ToolInterface
{
Expand Down Expand Up @@ -52,18 +52,4 @@ public function getParameterSchema(): array;
* @return mixed The tool result
*/
public function execute(array $arguments, mixed $context = null): mixed;

/**
* Convert to Anthropic API tool format.
*
* @return array{name: string, description: string, input_schema: array}
*/
public function toAnthropic(): array;

/**
* Convert to OpenAI API tool format.
*
* @return array{type: string, function: array{name: string, description: string, parameters: array}}
*/
public function toOpenAI(): array;
}
19 changes: 14 additions & 5 deletions src/EmbeddingResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,25 @@
*/
final class EmbeddingResponse
{
/**
* Token usage for the request.
*
* Typed as the neutral Usage value object; it is still array-accessible
* (`$response->usage['prompt_tokens']`) for backward compatibility.
*/
public readonly Usage $usage;

/**
* @param array<array<float>> $embeddings The embedding vectors
* @param string $model The model used
* @param array{prompt_tokens?: int, total_tokens?: int} $usage Token usage
* @param string $model The model used
* @param Usage|array $usage Token usage (a raw provider array is accepted and normalised)
*/
public function __construct(
public readonly array $embeddings,
public readonly string $model,
public readonly array $usage = [],
Usage|array $usage = [],
) {
$this->usage = is_array($usage) ? Usage::fromArray($usage) : $usage;
}

/**
Expand Down Expand Up @@ -71,7 +80,7 @@ public function dimensions(): int
*/
public function getPromptTokens(): int
{
return $this->usage['prompt_tokens'] ?? 0;
return $this->usage->inputTokens;
}

/**
Expand All @@ -81,6 +90,6 @@ public function getPromptTokens(): int
*/
public function getTotalTokens(): int
{
return $this->usage['total_tokens'] ?? 0;
return $this->usage->totalTokens;
}
}
32 changes: 22 additions & 10 deletions src/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,30 @@
final class Response
{
/**
* @param string $text The text response from the LLM
* @param array|null $data Parsed structured data (if output schema was used)
* @param array<ToolCall> $toolCalls Tool calls made by the LLM
* @param array<Message> $messages Full conversation history
* @param array $usage Token usage statistics
* @param string|null $stopReason Why the response ended
* Token usage for the request.
*
* Typed as the neutral Usage value object; it is still array-accessible
* (`$response->usage['input_tokens']`) for backward compatibility.
*/
public readonly Usage $usage;

/**
* @param string $text The text response from the LLM
* @param array|null $data Parsed structured data (if output schema was used)
* @param array<ToolCall> $toolCalls Tool calls made by the LLM
* @param array<Message> $messages Full conversation history
* @param Usage|array $usage Token usage (a raw provider array is accepted and normalised)
* @param string|null $stopReason Why the response ended
*/
public function __construct(
public readonly string $text,
public readonly ?array $data = null,
public readonly array $toolCalls = [],
public readonly array $messages = [],
public readonly array $usage = [],
Usage|array $usage = [],
public readonly ?string $stopReason = null,
) {
$this->usage = is_array($usage) ? Usage::fromArray($usage) : $usage;
}

/**
Expand Down Expand Up @@ -67,7 +76,7 @@ public function hasData(): bool
*/
public function getInputTokens(): int
{
return $this->usage['input_tokens'] ?? 0;
return $this->usage->inputTokens;
}

/**
Expand All @@ -77,7 +86,7 @@ public function getInputTokens(): int
*/
public function getOutputTokens(): int
{
return $this->usage['output_tokens'] ?? 0;
return $this->usage->outputTokens;
}

/**
Expand All @@ -87,12 +96,15 @@ public function getOutputTokens(): int
*/
public function getTotalTokens(): int
{
return $this->getInputTokens() + $this->getOutputTokens();
return $this->usage->totalTokens;
}

/**
* Create from an Anthropic API response payload.
*
* @deprecated Response mapping now lives in each provider package (see AnthropicProvider); build a
* Response with the neutral constructor instead. Kept for backward compatibility.
*
* @param array $response Raw Anthropic API response
* @param array<Message> $messages Conversation history to attach
*
Expand Down
14 changes: 12 additions & 2 deletions src/Tool.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,12 @@ public function execute(array $arguments, mixed $context = null): mixed
}

/**
* Convert to format expected by Anthropic API.
* Convert to the format expected by the Anthropic API.
*
* @deprecated Tool wire formatting now lives in each provider package; the Agent passes a neutral
* definition (name, description, parameters) and the provider formats it. Kept for BC.
*
* @return array{name: string, description: string, input_schema: array}
*/
public function toAnthropic(): array
{
Expand All @@ -221,7 +226,12 @@ public function toAnthropic(): array
}

/**
* Convert to format expected by OpenAI API.
* Convert to the format expected by the OpenAI API.
*
* @deprecated Tool wire formatting now lives in each provider package; the Agent passes a neutral
* definition (name, description, parameters) and the provider formats it. Kept for BC.
*
* @return array{type: string, function: array{name: string, description: string, parameters: array}}
*/
public function toOpenAI(): array
{
Expand Down
3 changes: 3 additions & 0 deletions src/ToolCall.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public function __construct(
/**
* Create from an Anthropic API tool_use content block.
*
* @deprecated Provider mapping now lives in each provider package; construct ToolCall directly.
* Kept for backward compatibility.
*
* @param array{id: string, name: string, input?: array} $data Anthropic tool_use block
*
* @return self Parsed tool call
Expand Down
123 changes: 123 additions & 0 deletions src/Usage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

/*
* This file is part of PapiAI,
* A simple but powerful PHP library for building AI agents.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace PapiAI\Core;

use ArrayAccess;
use LogicException;

/**
* Immutable, provider-neutral token usage for a request.
*
* Normalises the different key names providers use (Anthropic `input_tokens`/`output_tokens`,
* OpenAI `prompt_tokens`/`completion_tokens`) into `inputTokens`/`outputTokens`/`totalTokens`.
*
* Implements ArrayAccess against the legacy key names purely for backward compatibility, so code
* written against the old raw usage array (for example `$response->usage['input_tokens']`) keeps
* working. New code should read the typed properties, or the getters on Response/EmbeddingResponse.
*
* @implements ArrayAccess<string, int|mixed>
*/
final class Usage implements ArrayAccess
{
public readonly int $totalTokens;

/**
* @param int $inputTokens Prompt/input tokens
* @param int $outputTokens Completion/output tokens
* @param int|null $totalTokens Total tokens; defaults to inputTokens + outputTokens
* @param array<string, mixed> $raw The original provider usage payload, preserved verbatim
*/
public function __construct(
public readonly int $inputTokens = 0,
public readonly int $outputTokens = 0,
?int $totalTokens = null,
private readonly array $raw = [],
) {
$this->totalTokens = $totalTokens ?? ($this->inputTokens + $this->outputTokens);
}

/**
* Build from a raw provider usage array, mapping the known key aliases.
*
* @param array<string, mixed> $usage Provider usage payload in any of the known key styles
*/
public static function fromArray(array $usage): self
{
$input = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
$output = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
$total = $usage['total_tokens'] ?? null;

return new self(
(int) $input,
(int) $output,
$total !== null ? (int) $total : null,
$usage,
);
}

/**
* The original provider usage payload, preserved verbatim.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->raw;
}

/**
* @deprecated Legacy array-access shim; read the typed properties (inputTokens, etc.) instead.
*/
public function offsetExists(mixed $offset): bool
{
return match ($offset) {
'input_tokens', 'prompt_tokens', 'output_tokens', 'completion_tokens', 'total_tokens' => true,
default => isset($this->raw[$offset]),
};
}

/**
* @deprecated Legacy array-access shim; read the typed properties (inputTokens, etc.) instead.
*/
public function offsetGet(mixed $offset): mixed
{
return match ($offset) {
'input_tokens', 'prompt_tokens' => $this->inputTokens,
'output_tokens', 'completion_tokens' => $this->outputTokens,
'total_tokens' => $this->totalTokens,
default => $this->raw[$offset] ?? null,
};
}

/**
* @deprecated Usage is immutable; this shim only exists to satisfy ArrayAccess.
*
* @throws LogicException Always, because Usage is immutable
*/
public function offsetSet(mixed $offset, mixed $value): void
{
throw new LogicException('Usage is immutable.');
}

/**
* @deprecated Usage is immutable; this shim only exists to satisfy ArrayAccess.
*
* @throws LogicException Always, because Usage is immutable
*/
public function offsetUnset(mixed $offset): void
{
throw new LogicException('Usage is immutable.');
}
}
3 changes: 2 additions & 1 deletion tests/Unit/AgentBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
it('accepts tools', function () {
$tool = Mockery::mock(ToolInterface::class);
$tool->allows('getName')->andReturn('test_tool');
$tool->allows('toAnthropic')->andReturn([]);
$tool->allows('getDescription')->andReturn('A test tool');
$tool->allows('getParameterSchema')->andReturn(['type' => 'object', 'properties' => []]);

$this->provider->expects('chat')->andReturn(new Response(text: 'OK'));

Expand Down
27 changes: 27 additions & 0 deletions tests/Unit/AgentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,33 @@
$agent->run('Hello');
});

it('passes neutral tool definitions to the provider (not the Anthropic shape)', function () {
$tool = Tool::make(
name: 'lookup',
description: 'Look something up',
parameters: ['query' => ['type' => 'string', 'description' => 'the query']],
handler: fn () => 'ok',
);

$this->mockProvider
->expects('chat')
->withArgs(function ($messages, $options) use ($tool) {
$def = $options['tools'][0] ?? [];

return $def === [
'name' => 'lookup',
'description' => 'Look something up',
'parameters' => $tool->getParameterSchema(),
]
&& !array_key_exists('input_schema', $def);
})
->andReturn(new Response(text: 'OK'));

$agent = new Agent(provider: $this->mockProvider, model: 'test-model', tools: [$tool]);

$agent->run('Hi');
});

it('executes tools when provider requests them', function () {
$toolExecuted = false;

Expand Down
Loading
Loading