sentience / ai
The AI connector of Sentience
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.13
README
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,
- enable streaming over a PHP
Generator, reading SSE chunks incrementally, - and have tool calls loop to completion without writing that loop yourself.
All of that is one fluent call chain on a Prompt object, returning a
Generator that yields ResponseInterface values through each round of
the conversation.
Installation
composer require sentience/ai
Requires PHP 8.3 or newer. The only runtime dependency is guzzlehttp/guzzle.
Providers
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).
Usage
Connect
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') );
A basic prompt
foreach ($ai ->prompt('gpt-4o-mini', 'Summarise the plot of Moby-Dick in two sentences.') ->execute() as $response ) { 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.
System prompt and conversation history
foreach ($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() as $response ) { echo $response->getContent(); }
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.
Attachments
foreach ($ai ->prompt('gpt-4o', 'What is in this image?') ->withAttachment('/path/to/photo.png') ->withAttachment('/path/to/notes.txt') ->execute() as $response ) { echo $response->getContent(); }
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
Tools can be a closure, a callable, or any class implementing ToolInterface.
The simplest form is a closure:
foreach ($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() as $response ) { echo $response->getContent(); }
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, 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.
The tool-call loop runs automatically inside the generator. Each round of
the conversation yields a ResponseInterface that you can inspect or
consume before the loop continues to the next round.
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 { /* ... */ } } foreach ($ai ->prompt('claude-3-5-sonnet-latest', '...') ->withToolInterface(new SearchTool()) ->execute() as $response ) { echo $response->getContent(); }
Structured output
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(), ]); foreach ($ai ->prompt('gpt-4o', 'Analyse this article: ...') ->withStructuredOutput($schema) ->execute() as $response ) { $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).
Streaming
use Sentience\Ai\Apis\Length; $generator = $ai ->prompt('gpt-4o-mini', 'Write a short essay on tide pools.') ->withStream() ->execute(); foreach ($generator as $response) { $response->read(Length::Small); echo $response->getContent(); }
Calling withStream() enables streaming mode on the HTTP request. The
execute() method now returns a Generator that yields ResponseInterface
objects. Each iteration yields the current response, and you call read() on
it to pull the next chunk of SSE data from the stream. The read() method
accepts a Length enum value (or an int byte count) to control chunk size:
| Case | Bytes |
|---|---|
ExtraSmall |
1024 |
Small |
4096 |
Medium |
8192 |
Large |
16384 |
ExtraLarge |
65536 |
The same loop handles content deltas, reasoning deltas (where the model emits
them), tool-call assembly, and the tool-call loop itself. When the model
requests tools, the generator automatically executes them, appends the results
to the conversation, and yields the next round — all within the same foreach.
For a non-streaming request, call readAll() on the yielded response to
consume the full body at once:
$generator = $ai ->prompt('gpt-4o-mini', 'Write a short essay on tide pools.') ->execute(); foreach ($generator as $response) { $response->readAll(); } echo $response->getContent();
Schemas
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.
Continuing the Conversation
If your application needs to maintain state across multiple user interactions (e.g., a chat interface), you can use ->continue() on the generator. This method adds a new user message to the conversation history and re-sends the request, picking up exactly where the previous call left off.
$generator = $ai ->prompt('gpt-4o', 'I have two dogs: Buster and Daisy.') ->execute(); foreach ($generator as $response) { echo $response->getContent(); } $newGenerator = $ai->continue('Can you think of cool last names for these dogs', 'gpt-3.5'); foreach ($newGenerator as $response) { echo $response->getContent(); }
Architecture
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
-> ResponseGenerator // owns the tool-call loop, yields responses
-> 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 the stream flag.execute()returns aGeneratorviaResponseGenerator.Sentience\Ai\Apis\ResponseGenerator- anIteratorAggregatethat wraps the tool-call loop. It yieldsResponseInterfaceobjects, re-sending with tool results until the model stops. In streaming mode, each yielded response can be consumed incrementally withread().Sentience\Ai\Apis\ResponseAbstract- base class for provider responses. Handles both streaming and non-streaming HTTP bodies. Streaming reads SSE data viaread()(withLengthenum chunk sizes) and exposesreadAll()for full consumption. Each provider subclass implementshandleSseData(),isStreamEnd(), andfinalizeStream().Sentience\Ai\Apis\Length- an enum of chunk sizes (ExtraSmallthroughExtraLarge) that controls how many bytes are read perread()call.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.