The AI connector for the Sentience framework.
Sentience AI is a small, opinionated PHP library for talking to large language models. It targets PHP 8.3 and wraps two underlying API shapes - the OpenAI chat completions API and the Anthropic messages API - behind one consistent interface. Anything that speaks OpenAI's protocol (OpenRouter, local inference servers, compatible gateways) works through the OpenAI driver by pointing it at a different base URI.
The goal is not to be a kitchen-sink SDK. It is to give a PHP application a clean way to:
- send a prompt to a model,
- attach images and files,
- expose tools the model can call,
- ask for a structured JSON response,
- stream tokens as they arrive,
- and have tool calls loop to completion without writing that loop yourself.
All of that is one fluent call chain on a Prompt object.
composer require sentience/aiRequires PHP 8.3 or newer. The only runtime dependency is guzzlehttp/guzzle.
Three providers ship out of the box, defined on the Sentience\Ai\Api enum:
| Provider | Driver | Notes |
|---|---|---|
OpenAI |
OpenAIApi |
The OpenAI chat completions API. |
OpenRouter |
OpenAIApi |
OpenAI-compatible; point the base URI at https://openrouter.ai. |
Anthropic |
AnthropicApi |
The Anthropic messages API. |
Both drivers share ApiAbstract, so the message-building, attachment
formatting, and structured-output handling live in one place. The per-provider
classes only deal with the wire format differences (message roles, tool call
shapes, image content blocks, and SSE streaming events).
use Sentience\Ai\Api;
use Sentience\Ai\Ai;
$ai = Ai::connect(
Api::OpenAI,
baseUri: 'https://api.openai.com/v1/',
apiKey: getenv('OPENAI_API_KEY')
);For OpenRouter, swap the enum and the base URI:
$ai = Ai::connect(
Api::OpenRouter,
baseUri: 'https://openrouter.ai/api/v1/',
apiKey: getenv('OPENROUTER_API_KEY')
);For Anthropic:
$ai = Ai::connect(
Api::Anthropic,
baseUri: 'https://api.anthropic.com/',
apiKey: getenv('ANTHROPIC_API_KEY')
);$response = $ai
->prompt('gpt-4o-mini', 'Summarise the plot of Moby-Dick in two sentences.')
->execute();
echo $response->getContent();Models can be passed as a plain string or as any backed enum; backed enums are
resolved to their .value automatically, which is handy when models live in
your own enum.
$response = $ai
->prompt('claude-3-5-sonnet-latest', 'What did I just ask you about?')
->withSystemPrompt('You are a terse assistant. Answer in one sentence.')
->withPreviousMessages($priorTurns)
->execute();withPreviousMessages accepts an array of message objects (UserMessage,
AssistantMessage, ToolMessage). These are the same message types the
library produces internally, so you can round-trip a previous response through
AssistantMessage::fromResponse($response) and feed it back in.
$response = $ai
->prompt('gpt-4o', 'What is in this image?')
->withAttachment('/path/to/photo.png')
->withAttachment('/path/to/notes.txt')
->execute();Images (png, jpg, jpeg, gif, webp, bmp) are sent as image content
blocks. Anything else is decoded and embedded as a fenced text block so the
model sees the file contents directly. There are also withBase64Attachment
and withRawAttachment entry points for when the bytes are already in memory.
Tools can be a closure, a callable, or any class implementing ToolInterface.
The simplest form is a closure:
$response = $ai
->prompt('gpt-4o', 'What is the weather in Amsterdam?')
->withTool(
name: 'get_weather',
tool: function (string $city): string {
return "{$city}: 14C, light rain";
},
description: 'Get the current weather for a city.'
)
->execute();If you do not supply an explicit schema, the library reflects on the closure's
parameters and builds one for you. Supported parameter types are bool, int,
float, string, and array. Nullable types are honoured. This is enough for
the vast majority of tools; for anything fancier, pass a Schemable schema
explicitly.
When execute() is called with the default $loop = true, the library will:
- send the prompt,
- collect any tool calls in the response,
- run them against the registered tools,
- append the assistant message and each tool result to the conversation,
- and re-send, repeating until the model stops calling tools.
Pass execute(loop: false) to get a single round and handle the tool calls
yourself.
For class-based tools, implement ToolInterface and register with
withToolInterface:
final class SearchTool implements ToolInterface
{
public function name(): string { return 'search'; }
public function description(): string { return 'Search the knowledge base.'; }
public function schema(): array { /* ... */ }
public function execute(array $arguments): string { /* ... */ }
}
$response = $ai
->prompt('claude-3-5-sonnet-latest', '...')
->withToolInterface(new SearchTool())
->execute();Ask for a JSON object back by passing an ObjectType schema:
use Sentience\Ai\Schema\Schema;
$schema = Schema::object([
'title' => Schema::string(),
'summary' => Schema::string()->maxLength(280),
'tags' => Schema::array(Schema::string()),
'sentiment' => Schema::enum(['positive', 'neutral', 'negative']),
'confidence' => Schema::float()->nullable(),
]);
$response = $ai
->prompt('gpt-4o', 'Analyse this article: ...')
->withStructuredOutput($schema)
->execute();
$data = $response->getStructuredOutput();The schema is injected as a system message instructing the model to return
minified JSON conforming to the schema. On the way back, getStructuredOutput
parses the response, handling both raw JSON and ```json fenced
blocks, and returns the decoded array (or null if the response was not
marked as structured).
$response = $ai
->prompt('gpt-4o-mini', 'Write a short essay on tide pools.')
->withStream(function (StreamedResponse $partial) {
echo $partial->getContent();
})
->execute();The callback receives a StreamedResponse on every update. The same callback
fires for content deltas, reasoning deltas (where the model emits them), and
the final tool-call assembly when the stream finishes. The returned
ResponseInterface is the final accumulated state.
The Schema facade produces JSON Schema fragments. Every type comes in
required and optional variants; the required flag on the factory selects
between them. Required properties end up in the resulting object's required
array automatically.
Available types:
Schema::bool()Schema::int()Schema::float()Schema::string()- supportsminLength,maxLength,pattern,formatSchema::enum(array $values)Schema::array(Schemable|array $items)Schema::object(array $properties)
Every type is fluent: ->nullable(), ->description(string), and the string
constraints chain. Anything implementing Schemable can be passed in places
that ask for a schema, which is how custom tool schemas and structured output
share the same machinery.
The public surface is small and the layering is intentional:
Ai // entry point, picks a driver
-> ApiInterface (OpenAI|Anthropic) // translates to the wire format
-> Prompt // fluent builder, owns the execute loop
-> ResponseInterface // content, reasoning, tool calls, finish reason
Sentience\Ai\Ai- the facade. Connects, dispatches on theApienum, and starts prompts.Sentience\Ai\Apis\ApiAbstract- shared behaviour: image detection, MIME handling, multipart content building, the structured-output system message, and the/v1/modelslisting.Sentience\Ai\Apis\OpenAI\OpenAIApiandSentience\Ai\Apis\Anthropic\AnthropicApi- per-provider message and attachment formatting plus SSE stream parsing.
Sentience\Ai\Prompt- the builder. Holds the prompt, system prompt, conversation history, attachments, tools, max tokens, structured output, and stream callback.execute()owns the tool-call loop.Sentience\Ai\Schema- the schema factory and type hierarchy.Sentience\Ai\Tools-Tool(closure-backed, with reflection-driven schema generation) andToolInterfacefor class-based tools.Sentience\Ai\Messages-UserMessage,AssistantMessage,ToolMessage,SystemMessage, and theRoleenum.Sentience\Ai\Attachments\Base64Attachment- file and inline attachment helper.
Adding a provider means implementing ApiInterface (or extending
ApiAbstract) and adding a case to the Api enum plus a branch in Ai's
constructor. Everything above the driver - prompt building, schemas, tools,
attachments, the loop - is shared.