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->text

16.8: Safety

We use essential cookies to make this site work. With your permission we also use analytics to improve the site.