thinwrap / llm
Unified, SDK-free PHP facade over LLM chat & embeddings APIs — OpenAI, Anthropic, Bedrock, Gemini and 14 more behind one typed interface. Stateless, no vendor SDKs; BYO PSR-18 HTTP client, with php-http/discovery as the only runtime dependency (auto-wires a client when none is injected).
Requires
- php: ^8.2
- php-http/discovery: ^1.20
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.0 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- guzzlehttp/psr7: ^2.9
- nyholm/psr7: ^1.8
- php-http/mock-client: ^1.6
- phpstan/phpstan: ^2.1.54
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Unified, SDK-free PHP facade over LLM chat-completion & embeddings providers. One typed Chat facade; switch vendor by changing the provider id + model. Stateless, bring your own PSR-18 HTTP client, no vendor SDKs — the in-process, zero-egress complement to an LLM gateway.
Install
composer require thinwrap/llm
Requires PHP ≥8.2. A PSR-18 HTTP client + PSR-17 factories are auto-discovered via
php-http/discovery — if you don't already have one installed:
composer require guzzlehttp/guzzle guzzlehttp/psr7
End-to-end example
use Thinwrap\Llm\Chat; use Thinwrap\Llm\DTO\Chat\ChatInput; use Thinwrap\Llm\DTO\Chat\ChatMessage; use Thinwrap\Llm\Enum\ChatRole; use Thinwrap\Llm\Enum\LlmProviderId; use Thinwrap\Llm\Exception\ConnectorError; use Thinwrap\Llm\Providers\Shared\OpenAiCompatConfig; $chat = new Chat(LlmProviderId::OpenAI, new OpenAiCompatConfig(apiKey: getenv('OPENAI_API_KEY'))); try { $res = $chat->complete(new ChatInput( model: 'gpt-4o-mini', messages: [new ChatMessage(ChatRole::User, 'Say hi in one word.')], )); echo $res->message->content; // → the reply echo $res->usage->inputTokens . '/' . $res->usage->outputTokens; } catch (ConnectorError $e) { error_log($e->providerCode->value . ': ' . ($e->providerMessage ?? '')); }
Switching providers
Change the LlmProviderId case, the config class, and the model; the input and
output shape stay identical.
use Thinwrap\Llm\Providers\Anthropic\AnthropicConfig; use Thinwrap\Llm\Providers\Gemini\GeminiConfig; use Thinwrap\Llm\Providers\Bedrock\BedrockConfig; $anthropic = new Chat(LlmProviderId::Anthropic, new AnthropicConfig(apiKey: getenv('ANTHROPIC_API_KEY'))); $gemini = new Chat(LlmProviderId::Gemini, new GeminiConfig(apiKey: getenv('GEMINI_API_KEY'))); $bedrock = new Chat(LlmProviderId::Bedrock, new BedrockConfig( region: 'us-east-1', accessKeyId: getenv('AWS_ACCESS_KEY_ID'), secretAccessKey: getenv('AWS_SECRET_ACCESS_KEY'), )); // same ->complete($input) shape, same ChatResult
The 15 first-class OpenAI-compatible providers all take OpenAiCompatConfig:
openai, azure-openai, openrouter, groq, together, fireworks, deepseek,
xai, mistral, perplexity, deepinfra, cloudflare, vllm, ollama, lmstudio.
anthropic, bedrock, and gemini are native adapters.
Per-provider docs (auth, config, quirks, rate-limit links, error mapping) live in
src/Providers/ — one README per provider.
Streaming
foreach ($chat->stream($input) as $delta) { if ($delta->contentDelta !== null) { echo $delta->contentDelta; } }
stream() returns a \Generator<ChatStreamDelta>. It reads the provider's
text/event-stream response off the PSR-7 body as the client hands it over. Bedrock
(whose stream uses AWS binary event-stream framing) falls back to a single Converse call
replayed as deltas.
Deltas arrive incrementally only if your PSR-18 client returns an unbuffered body.
Guzzle buffers by default: its sendRequest() reads the whole response before
returning, so every delta shows up at once when generation ends. Construct it with
stream on and inject it:
$chat = new Chat(LlmProviderId::OpenAI, $config, new \GuzzleHttp\Client(['stream' => true]));
With another client, check that it does not buffer the response body.
Embeddings
use Thinwrap\Llm\Embeddings; use Thinwrap\Llm\DTO\Embeddings\EmbeddingsInput; $emb = new Embeddings(LlmProviderId::OpenAI, new OpenAiCompatConfig(apiKey: getenv('OPENAI_API_KEY'))); $res = $emb->create(new EmbeddingsInput(model: 'text-embedding-3-small', input: ['hello', 'world'])); $res->embeddings; // list<list<float>>, one vector per input, in input order
Bring your own PSR-18 client
Inject any PSR-18 client (and, optionally, PSR-17 factories) as constructor
arguments — useful for tracing, retries, mocking, or proxying through
symfony/http-client.
Contract: a non-2xx must be RETURNED, not thrown. PSR-18 requires this and compliant
clients honour it, so each connector can map the status to a ProviderCode
(429 → RateLimited, 401 → AuthFailed, …) and read the vendor's message. For an LLM
caller that is the difference between backing off and treating a rate limit as an outage.
A client that raises instead — Guzzle used outside its PSR-18 adapter, or a decorator
calling raise-on-error — is handled defensively: the answered response is recovered from
the exception's getResponse() so classification still runs.
$chat = new Chat( LlmProviderId::OpenAI, new OpenAiCompatConfig(apiKey: getenv('OPENAI_API_KEY')), $myPsr18Client, // ?ClientInterface $myRequestFactory, // ?RequestFactoryInterface $myStreamFactory, // ?StreamFactoryInterface );
The wrapper holds no state — no cache, no connection pool, no retry buffer. For an
already-built connector (a custom provider, say), use Chat::fromConnector($connector).
The wrapper sets no timeout either: configure it on your client, and leave room for reasoning models, whose non-stream calls can run for minutes.
_passthrough escape valve
When the normalized input doesn't expose a vendor-specific field, forward arbitrary
keys via _passthrough on the input DTO. Every connector merges body, headers and
query (Bedrock signs the query too); consumer values win on collision. Keys are
forwarded verbatim.
The body merge is shallow: a top-level passthrough key replaces the connector's
value whole. Passing generationConfig to Gemini, for example, replaces the one the
connector built from maxOutputTokens / temperature / responseFormat, so restate
any of those you still want inside it.
new ChatInput( model: 'gpt-4o-mini', messages: [new ChatMessage(ChatRole::User, 'hi')], _passthrough: ['body' => ['logprobs' => true], 'headers' => ['X-Trace' => 'abc']], );
Carrying provider state between turns (providerMetadata)
Some providers need parts of their response sent back on the next request: Gemini's
thoughtSignature on each function call, and Anthropic's thinking blocks in a tool
loop. ToolCall, ChatMessage, AssistantMessage, ToolCallDelta and the terminal
ChatStreamDelta carry them in an optional providerMetadata array keyed by provider
id. Treat it as opaque. When you build the next request from a result, copy
providerMetadata along with content and toolCalls:
$turn = $res->message; $messages[] = new ChatMessage( ChatRole::Assistant, $turn->content, toolCalls: $turn->toolCalls, // each ToolCall keeps its own metadata providerMetadata: $turn->providerMetadata, );
A connector reads only its own key and never sends another provider's, so a history survives a provider switch. Losing the metadata costs Anthropic its earlier reasoning; Gemini falls back to Google's documented signature bypass, so the tool loop still runs.
Usage
inputTokens counts every billed input token (Anthropic and Bedrock cache reads and
writes, Gemini tool-use prompt tokens included) and outputTokens every billed output
token (Gemini thoughts included). totalTokens is their sum on Anthropic, Bedrock and
Gemini, and the provider's reported total on the OpenAI-compatible providers.
Error handling
Every failure surfaces as ConnectorError with a typed ProviderCode
(rate_limited, auth_failed, provider_unavailable, invalid_request,
context_length_exceeded, content_filtered, unknown). There is no top-level
retryAfterSeconds field — the raw Retry-After and its parsed seconds ride in
$e->cause. The wrapper performs no automatic retry. retryAfterSeconds is set only
for an RFC 9110 value: whole seconds, or an HTTP-date (0 once it has passed); anything
else (1.5, tomorrow) leaves it null.
408 reads as provider_unavailable (a timeout a retry can clear), 402 and 429 as
rate_limited, every other 4xx as invalid_request unless a more specific rule
matches, and every 5xx as provider_unavailable.
A stream that comes back as anything but text/event-stream, or that ends before the
provider's terminal event (a dropped connection, a proxy timeout), throws
provider_unavailable rather than ending as a short answer.
A 2xx is not automatically a success: a body that carries an error object (OpenRouter
reports failures after generation started this way), lacks the result payload
(choices, content, output, candidates, data), or returns the wrong number of
embeddings / a non-numeric vector cell also throws ConnectorError, with the body on
$e->cause['raw']. An error reported inside a 2xx body or a mid-stream frame keeps the
status the response arrived with as $e->statusCode (normally 200); its
providerCode comes from the error's own code or type, and is unknown when it has
neither.
Security
Report vulnerabilities privately — please do not open a public issue. Preferred: a private security advisory on this repository. Alternatively, email security@thinwrap.dev. Include the affected versions and a minimal reproduction if you have one.
Credentials stay out of what the wrapper itself can leak: config and connector
parameters are #[\SensitiveParameter] (out of stack traces), print_r() / var_dump()
of a config, connector or facade shows [redacted] (var_export() does not consult
__debugInfo() and still prints them), and a header value containing CR, LF or NUL —
an API key pasted with a trailing newline — is rejected with invalid_request before
any request is built, whichever PSR-7 library you use.
A vulnerability in a provider's own API or service belongs to that vendor rather than to this wrapper — please report those upstream.
MIT © Dmitry Polyanovsky