LLM

LLM is an abstract facade every provider adapter (Monad\Clarity\Services\LLMAdapters\{OpenAI,Anthropic,DeepSeek,Gemini}) implements: one complete() method translating a provider-agnostic request to that provider's wire format and back. No global registry — construct the adapter you want directly, with its own credentials and an HttpClient:

use Monad\Clarity\Services\HttpClient;
use Monad\Clarity\Services\LLMAdapters\Anthropic;
use Monad\Clarity\Services\LLM\LLMRequest;

$adapter = new Anthropic(apiKey: $apiKey, httpClient: new HttpClient());

$response = $adapter->complete(new LLMRequest(
    model: 'claude-opus-5',
    messages: [['role' => 'user', 'content' => 'Say hello in five words.']],
    systemInstruction: 'You are terse.',
));

$response->content; // the model's text reply

LLMRequest fields

new LLMRequest(
    string $model,
    array $messages,                    // [['role' => 'user'|'assistant', 'content' => string], ...]
    ?string $systemInstruction = null,   // a top-level field, never a message — providers disagree on system-role messages
    float $temperature = 1.0,            // 0.0–2.0
    int $maxOutputTokens = 1024,
    int $timeoutSeconds = 30,
    ?array $responseSchema = null,       // a JSON Schema the output must conform to; null = plain text
)

Validated at construction — a malformed request throws before any network round trip.

LLMResponse fields

$response->provider;         // e.g. 'anthropic'
$response->model;
$response->content;          // string, or array if $responseSchema was set and honoured
$response->usage;             // ['inputTokens' => int, 'outputTokens' => int]
$response->providerRequestId; // ?string
$response->raw;               // the provider's full decoded response body — an escape hatch

Structured JSON output

Set responseSchema to a JSON Schema and $response->content comes back as a decoded array instead of a string. Each provider has its own mechanism behind that one field — OpenAI and Gemini enforce the schema server-side, DeepSeek offers JSON mode without schema enforcement, and Anthropic has two mechanisms, neither of which reaches every model.

use Monad\Clarity\Services\LLMAdapters\AnthropicStructuredOutput;

$adapter = new Anthropic(
    apiKey: $apiKey,
    httpClient: new HttpClient(),
    structuredOutput: AnthropicStructuredOutput::NativeSchema,
);

Requires Clarity 1.8.0 or newer. ForcedTool is the default and the behaviour of every earlier release: a synthetic tool whose schema is yours, with the answer read out of the resulting tool call. It accepts any JSON Schema you can write, and the newest Anthropic models refuse it. NativeSchema uses Anthropic's own JSON-schema response mode, which those models require — but it is absent from several older ones and accepts a narrower range of schema: no recursion, no numeric or string bounds, and additionalProperties: false on every object. A rejected schema comes back as a precise error naming the offending keyword.

A model id does not say which mechanism it supports, so the choice is yours to make once, per adapter, rather than something the framework can guess per request. The default was left unchanged so existing code keeps the behaviour it already had.

Workspace-scoped Anthropic keys

$adapter = new Anthropic(
    apiKey: $apiKey,
    httpClient: new HttpClient(),
    workspaceId: 'wrkspc_01abc',
);

Requires Clarity 1.8.0 or newer. Only needed if your API key is not itself scoped to a workspace — Anthropic refuses such a key outright unless the request names one. Leave it unset otherwise and no header is sent.

Provider notes

  • OpenAI — the output cap is sent as max_completion_tokens. Requires Clarity 1.8.0; earlier releases sent max_tokens, which every current OpenAI chat model rejects, so the adapter could reach only older models.
  • Anthropic — temperature is sent only when you move it off the default of 1.0. Current Anthropic models refuse a non-default value; if you set one, it is sent as asked and the model decides.
  • Gemini — the model is part of the URL path and the key is a query parameter, both Google's own conventions. Assistant turns are translated to Gemini's model role so your message list stays provider-neutral.
  • DeepSeek — structured output is best-effort: DeepSeek guarantees valid JSON but does not enforce the schema server-side, so the adapter also states the schema in the system instruction.

What's deliberately not here

No agents, tool orchestration, vector databases, memory, prompt pipelines, or automatic cross-provider retries — a thin, provider-agnostic request/response contract, not an agentic framework.

Next steps

  • HttpClient — what every adapter sends requests over.