Package: ai
Generated from api/packages/ai
Source: api/packages/ai. The sections after the README are extracted from the code on every docs build.
README
Section titled “README”Provider-agnostic access to large language models for every ulams package (ADR 0009). It knows
nothing about courses: callers build an LlmRequest (task, prompt, content blocks, output JSON
Schema, subject) and get back validated JSON, with every call logged and priced.
use Ulams\Ai\Contracts\LlmClient;use Ulams\Ai\Dto\{ContentBlock, LlmRequest};
$result = app(LlmClient::class)->generate(new LlmRequest( task: 'outline', // → profile, effort, max tokens in config prompt: app(PromptRegistry::class)->get('course-builder', 'outline'), blocks: [ContentBlock::text($sourceDocument, cache: true), ContentBlock::text($instruction)], schema: $outlineSchema, // JSON Schema of the answer subject: ['type' => 'course_builder_session', 'id' => $session->id], validator: fn (array $data) => $semanticErrors, // optional, e.g. citations resolve));$result->data; // validated array$result->costMicroUsd; // integer micro-USDWhat it does
Section titled “What it does”| Concern | Where |
|---|---|
Models and profiles (default, light, opt-in premium), task → profile/effort/max tokens, prices, limits |
config/ai.php, all from env |
Driver selection: anthropic, fake, disabled (no key ⇒ disabled) |
UlamsAiServiceProvider::driverName() |
Anthropic driver: official anthropic-ai/sdk, streamed, structured outputs (output_config.format), effort, no tools, cache breakpoints, server-side refusal fallback |
src/Drivers/AnthropicDriver.php |
| Validation: JSON Schema (opis, draft 2020-12) + semantic validator, one repair attempt, then a failed step | src/Services/AiClient.php |
refusal and max_tokens stop reasons are failures, never partial content |
same |
Usage log ai_calls (tenant database): tokens, cache tokens, cost (micro-USD), latency, prompt id and version, model requested and served, subject |
src/Models/AiCall.php, php artisan ai:usage |
| Budgets per subject (tokens, USD) and per tenant per month, checked before every call | src/Services/BudgetGuard.php |
| Versioned prompt files | src/Prompts/PromptRegistry.php |
| Fake driver with cassettes; synthetic stand-ins for local demos | src/Drivers/FakeDriver.php, src/Fake/ |
No model id appears outside config/ai.php and .env.example; ModelNamesGuardTest fails on a
claude-… string in any package’s src/ or resources/prompts/. The UI shows the profile’s label
(AI_MODEL_DEFAULT_LABEL), not the id.
Prompt caching
Section titled “Prompt caching”Requests are laid out stable-first: the frozen system prompt (cached), then the content blocks the
caller marks cacheable (for the course builder: the source document, then brief and outline), then
the per-element instruction (uncached). Each marked block gets a breakpoint with AI_CACHE_TTL
(default 1h, because authors pause between steps); at most four breakpoints per request. Cache
writes are priced at 1.25× (5 min) or 2× (1 h) the input price, reads at the cache_read price.
cost_micro_usd = input × p_in + output × p_out + cache_read × p_read + write_5m × p_in × 1.25 + write_1h × p_in × 2, with prices in USD per million tokens from ai.prices (= micro-USD per token)
and the long_context tier when the input side of the call exceeds its threshold. The model that
actually answered (model_served, which differs from the requested one after a refusal fallback) is
the one priced. Costs are stored at call time and never recomputed.
Tests and cassettes
Section titled “Tests and cassettes”Unit and feature tests never reach the network: resolving the Anthropic driver in the testing
environment throws. Tests use the fake driver:
FakeDriver::queueJson($task, $data)for direct unit tests;- cassettes at
<AI_CASSETTES_PATH>/<task>/v<prompt version>/<hash>.json. The hash covers the task, the prompt version, the output schema and the request content normalised: fragment ids (frg_…) and ULIDs are replaced by ordinal placeholders, so a recording replays on a fresh database. A prompt or schema change without new cassettes fails withmissing_cassetteand the expected path; AI_FAKE_MODE=synthetic(default outside tests): when no cassette matches, a responder registered by the owning package (FakeResponders::register($task, fn)) builds a deterministic answer. This is what local demos without an API key and the end-to-end test run on.
Record cassettes from real runs with AI_RECORD=true AI_RECORD_PATH=<dir> (the course builder’s
course-builder:eval --record sets both).
vendor/bin/phpunit --testsuite aiAPI endpoints
Section titled “API endpoints”None.
Permissions
Section titled “Permissions”None.
Settings
Section titled “Settings”Registered with AdministrableConfig::registerConfig (editable in the admin panel under Configuration → Settings).
None.
Events
Section titled “Events”None.
Artisan commands
Section titled “Artisan commands”| Command | Description | Signature |
|---|---|---|
ai:usage |
AI calls, tokens and cost per day and task for this tenant | ai:usage {--days=30 : How many days back} |
Scheduled jobs
Section titled “Scheduled jobs”None.
Environment variables read
Section titled “Environment variables read”AI_ALLOW_NETWORK_IN_TESTS, AI_CACHE_TTL, AI_CASSETTES_PATH, AI_DEFAULT_FALLBACKS, AI_DRIVER, AI_FAKE_MODE, AI_FALLBACKS_BETA, AI_LIMIT_SUBJECT_COST_USD, AI_LIMIT_SUBJECT_TOKENS, AI_LIMIT_TENANT_MONTHLY_USD, AI_MAX_RETRIES, AI_MODEL_DEFAULT, AI_MODEL_DEFAULT_LABEL, AI_MODEL_LIGHT, AI_MODEL_LIGHT_LABEL, AI_MODEL_PREMIUM, AI_MODEL_PREMIUM_LABEL, AI_PREMIUM_FALLBACKS, AI_RECORD, AI_RECORD_PATH, AI_TASK_GROUNDING_PROFILE, AI_TASK_INTERACTION_H5P_PROFILE, AI_TASK_INTERACTION_INTERACTIVE_PROFILE, AI_TASK_INTERVIEW_PROFILE, AI_TASK_LESSON_PROFILE, AI_TASK_METADATA_PROFILE, AI_TASK_OUTLINE_PROFILE, AI_TASK_PATCH_PROFILE, AI_TASK_PRICE_PROFILE, AI_TASK_QUIZ_PROFILE, AI_TASK_SELFCHECK_PROFILE, AI_TASK_UPDATE_PROFILE, AI_TIMEOUT, ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL