16: AI
Phlo bundles a number of AI providers (OpenAI, Claude, Gemini, DeepSeek, Grok) behind a single facade. You pick a model, Phlo picks the right engine. Streaming to the DOM uses the same apply() mechanics as the rest of Phlo: no separate client-side library, no separate event bus.
16.1: Resources
Add to data/app.json:
{
"resources": [..., "AI/AI", "AI/OpenAI"]
}
One file per provider. AI/AI is the facade, which routes to the right engine based on the model:
| Model contains | Engine |
|---|---|
gpt-*, o1-*, o3-*, o4-*, chatgpt-* |
OpenAI |
claude-* |
Claude |
deepseek-* |
DeepSeek |
gemini-* |
Gemini |
grok-* |
Grok |
Or explicitly via the via: argument: %AI->chat(via: 'claude', model: ...).
The model argument is optional. Without one the facade falls back to %app->model and then to its own default, gpt-5.4-mini (which routes to OpenAI), so an app with just an OpenAI key in creds.ini works out of the box. Set %app->model to change the default per app, or override %AI.model with a build mod to change it system-wide, including the default engine.
The facade exposes the same methods for every engine, but not every provider backs every one:
| Engine | chat | stream | tools | vision | embeddings | transcribe |
|---|---|---|---|---|---|---|
| OpenAI | yes | yes | yes | yes | native | yes |
| Claude | yes | yes | yes | yes | OpenAI | no |
| Gemini | yes | yes | yes | yes | native | no |
| DeepSeek | yes | yes | yes | no | OpenAI | no |
| Grok | yes | yes | yes | yes | OpenAI | no |
OpenAI in the embeddings column means the engine has no embedding model of its own and delegates to OpenAI, so it also needs an OpenAI key. DeepSeek and Grok are thin layers on top of OpenAI (same protocol, different endpoint and key), so they share its method set; a no cell means the provider has no model or endpoint behind that call and it will error. The matrix is the source of truth: only invoke a capability that is marked for the engine you target.
Credentials go in data/creds.ini:
OpenAI = sk-...
Claude = sk-ant-...
Grok = xai-...
Phlo's security/creds loads them automatically into %creds->OpenAI etc. See Configuration for the full credential format, environment variables and precedence.
16.2: A single answer
Short question, one answer:
$answer = %AI->chat(
model: 'gpt-4o-mini',
user: 'Summarize this article: '.$article->text,
)
echo $answer->answer
Or even shorter, via the answer helper:
$verdict = answer('Is "carrot" a vegetable?', 'yes', 'no', 'maybe')
answer() is built into AI/answer. It makes one call with a low temperature and returns only the purest answer. With options it becomes a choice from the given possibilities.
16.3: Streaming to the DOM
This is where Phlo's apply() protocol really shines. An async route that writes token by token into an element:
route async POST chat::ask {
%res->streaming = true
foreach (%AI->stream(user: %payload->question) AS $chunk){
if (isset($chunk->text)) apply(append: arr('#answer' => $chunk->text))
}
}
Set %res->streaming = true and every apply() flushes to the client the moment you call it, instead of being buffered until the response ends. Each token is appended to #answer through the same apply() protocol you use everywhere else: no SSE plumbing, no manual flush(), no JS to write and no state to manage, a streaming UI right away.
16.4: Tools (function calling)
$tool = obj(
name: 'get_weather',
desc: 'Get the current weather for a location',
args: arr(
location: arr(type: 'string', desc: 'City and country, e.g. "Paris, FR"'),
),
)
$res = %AI->chat(
model: 'gpt-4o-mini',
user: 'What is the weather in Amsterdam?',
tools: [OpenAI::tool($tool)],
)
foreach ($res->tools ?? [] AS $call){
if ($call->name === 'get_weather') weather::fetch($call->args['location'])
}
Tool calls come back under $res->tools as an array of {name, args}. Phlo's facade normalizes the provider differences.
16.5: Vision
$res = %AI->vision('What is in this photo?', '/uploads/photo.jpg')
echo $res->answer
Works with OpenAI, Claude, Gemini and Grok.
16.6: Embeddings
$vector = %AI->embedding('Phlo is a compile-to-PHP framework', model: 'text-embedding-3-small')
%vectors->store(id: 'doc-1', vector: $vector, meta: ['source' => 'about'])
The default model is provider-specific. For OpenAI it is text-embedding-3-small.
16.7: Transcribe
$file = %files->save(%payload->file('audio'))
$res = %AI->transcribe($file, model: 'whisper-1', language: 'nl')
echo $res->text16.8: Safety
- AI calls are expensive and non-deterministic. Cache aggressively,
%apcufor session scope,JSONDBfor longer TTLs. - Filter user input before you put it in a prompt. Phlo's
esc()is for HTML; for prompts use your own sanitizer or a strict tool schema. - Logging prompts/answers can have privacy implications. By default Phlo does not log; your
data/errors.jsononly sees exceptions.