Search by

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).

v1.1.0 2026-10-18 17:52 UTC

This package is auto-updated.

Last update: 2026-10-18 17:54:56 UTC


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