Fluxon Docs
Batteries

ai — LLM (first-class primitive)

ai.ask, ai.json with confidence scores, the ai.run agent tool loop, and connecting any OpenAI-compatible provider.

This is the biggest thing that sets Fluxon apart from other languages. The LLM is a keyword, not an SDK. The provider is detected automatically (OS env or .env) — you configure nothing:

  • if ANTHROPIC_API_KEY is set → Claude (default claude-opus-4-8)
  • if OPENAI_API_KEY is set → GPT (default gpt-4o)
  • if both are set, Anthropic wins. Override: $AI_PROVIDER (anthropic|openai), $AI_KEY (a shared key), $AI_MODEL (the model name).

This adapts to common standard names like OPENAI_API_KEY/ANTHROPIC_API_KEY — the same .env works with other tools.

use ai

# Simple question-and-answer → text
answer = ai.ask "Translate this message into English: ${text}"

# Structured extraction (typed extraction) → a map according to the schema
schema = {
  intent: ":new_order|:question|:other"
  items: [{product:str qty:int}]
}
r = ai.json "Extract the order: ${text}" schema
# r.intent, r.items[0].product ...

Audit metadata — automatic

Each ai.* result carries metadata under _:

r = ai.json prompt schema
log r._.conf        # confidence score (0..1)
log r._.tokens      # tokens used
log r._.cost        # cost
log r._.ms          # latency (milliseconds)

This is the basis for confidence routing:

if r._.conf > 0.85
  auto_answer r         # high confidence → automatic
elif r._.conf >= 0.6
  ask_owner r           # medium → ask for confirmation
else
  escalate_to_owner r   # low → full escalation

Note

_.conf is the calibrated confidence returned by the LLM battery. In real life this should be backed by logprobs or self-eval; the language hides this behind the battery.

ai.run — agent tool loop (ONE step)

If the AI wants to use a tool, ai.run does not execute it itself — it returns to you what it wants to do. You run the tool (with logging, cost, confirmation) and return the result. The loop is manual — this gives you full control:

msgs <- [{role::user content:text}]
each i in 1..10                          # maximum 10 steps
  r = ai.run msgs tools                  # tools: a list of [{name desc params}]
  if r.kind == :final
    ret r.text                           # AI is done → final answer
  # r.kind == :call → the AI wants to call tools. The model may call several
  # in parallel → all are in r.calls; return a result for EACH one.
  each c in r.calls
    out = reg.call c.tool c.args         # run the tool by name (see below)
    log "tool ${c.tool}"                 # logging/cost/confirmation goes here
    msgs <- msgs.push {role::tool id:c.id content:(json.enc out)}

r.tool/r.args/r.id mirror r.calls[0] for back-compat (single-tool code still works). But on parallel calls you must return a result for every tool_use_id, otherwise the next request 400s. ai.run is intentionally single-step. If you let the AI's tool calls run automatically and uncontrolled, you could not do logging/cost/confirmation. The loop is yours — so you see and control every tool call.

See reg for the registry that turns a tool name into a callable function.

Connecting to other providers (advanced)

Everything above works with zero config — a standard key in .env is all you need, and most programs never go further. But the ai battery also speaks any OpenAI-compatible API (Z.AI / GLM, OpenRouter, Ollama, vLLM, LM Studio, Azure, Groq, …). You override the parts of the request that differ, and nothing else changes — the default behavior is byte-for-byte the same when you pass no overrides.

There are two ways to override, and they compose (per-call wins over global).

ai.config {…} — set global defaults once, at the top of the program (like http.cors; call it before http.serve). The keys, all optional:

keymeaning
urlfull endpoint URL (replaces the provider default)
stylewire format: :openai or :anthropic (the request/response shape)
keyAPI key (same role as $AI_KEY, inline)
modelmodel name (same role as $AI_MODEL, inline)
headersextra HTTP headers, merged onto the defaults (hyphenated names like HTTP-Referer must be string keys)
extraextra request-body fields, merged into the JSON

A map key is a bare identifier, which cannot contain -. HTTP header names with a hyphen must therefore be written as string keys: {"HTTP-Referer": "…" "X-Title": "…"}. Header names are matched case-insensitively (and sent lowercased), so a per-call header replaces a global one regardless of casing.

# GLM (Z.AI): the OpenAI wire format at a different URL — that's the whole change.
ai.config {
  style: :openai
  url:   "https://api.z.ai/api/paas/v4/chat/completions"
  key:   env.ZAI_KEY
  model: "glm-4.6"
}
answer = ai.ask "Salom, dunyo"          # now goes to Z.AI

# OpenRouter: extra body params + its recommended headers.
ai.config {
  style:   :openai
  url:     "https://openrouter.ai/api/v1/chat/completions"
  key:     env.OPENROUTER_KEY
  model:   "anthropic/claude-3.5-sonnet"
  headers: {"HTTP-Referer": "https://myapp.dev" "X-Title": "My App"}  # hyphenated → string keys
  extra:   {provider: {sort: "throughput"}}   # OpenRouter-specific knob
}

A trailing opts map on any ai.ask/ai.json/ai.run overrides the global config for that one call (same keys). Omit it and the call behaves exactly as before — it is purely additive:

# Same global config, but this one call uses a cheaper/faster model.
r = ai.ask "quick check" {model: "glm-4.5-air"}
r = ai.json prompt schema {model: "glm-4.6"}
r = ai.run msgs tools {extra: {temperature: 0}}
# To pass opts to ai.run WITHOUT tools, use nil for tools:
r = ai.run msgs nil {model: "glm-4.6"}

headers and extra merge key-by-key (a key you give wins; the rest of the defaults stay), while url/style/key/model replace. style selects only the wire format — a GLM endpoint speaks OpenAI, so style::openai + a custom url is enough; you don't need the official OpenAI key. Env equivalents exist for the scalars: $AI_STYLE, $AI_BASE_URL (alongside $AI_KEY / $AI_MODEL / $AI_PROVIDER).

A custom url requires an explicit key

When you point at a custom host, you must supply the key inline (key: / opts) or via $AI_KEY. A standard provider key ($OPENAI_API_KEY / $ANTHROPIC_API_KEY) is never sent to a custom URL — so an OpenAI key in your environment can't leak to Z.AI/OpenRouter. Custom URL with no explicit key is a clear error before any network call.

On this page