Runtime
Models & Providers
A model profile defines how to connect to an LLM provider. Luma uses a provider-neutral adapter layer: the same .luma program runs unchanged against an OpenAI-compatible gateway, against Anthropic, against Gemini or against Azure — the tool call formats and the structured output rules are translated by the adapter.
Model profile
model Primary {
provider: "openai-compatible"
base_url: "https://gateway.example/v1"
model: "text-model-l"
api_key: getEnv("api_key")
timeout: 30000
max_tokens: 4000
capabilities: ["json_schema", "tool_calling"]
cache: true
cache_ttl: 60000
cache_max_entries: 256
stream: false
reasoning: false
multimodal: false
cost_per_1k_tokens: 0.002
}
agent Analyst {
model: Primary
}| Key | Required | Default | Description |
|---|---|---|---|
provider | yes | — | Adapter selection. The valid values are listed below. |
base_url | yes | — | Gateway root. The endpoint path is appended by the adapter. |
model | yes | — | The model identifier at the provider. |
api_key | yes | — | Only the getEnv("ENV_NAME") form is accepted. |
timeout | no | unlimited | Request timeout (ms). |
max_tokens | no | — | Output token limit. On Anthropic, 4096 is used when no value is given. |
capabilities | no | [] | The capabilities the capability probe will verify. |
cache | no | false | Model response cache. |
cache_ttl | no | 300000 | Lifetime of a cache entry (ms). |
cache_max_entries | no | 256 | LRU capacity. |
stream / streaming | no | false | SSE streaming — the response is still collected in full. |
reasoning | no | false | Adds reasoning_effort: "medium" to the request. |
multimodal | no | false | Turns on processing of the image/file markers inside the prompt. |
cost_per_1k_tokens | no | the agent's rate | The unit price of this model per 1000 tokens. It overrides the agent's cost_per_1k_tokens for every request this model serves; the agent's token_budget / cost_budget keep reading the same ledger. A negative value is a parse error: Model 'Primary' cost_per_1k_tokens cannot be negative. |
Per-model pricing matters when one agent runs several models: without it a cheap and an expensive model are billed at the same rate, and the escalate policy's max_cost_per_call ceiling would measure tokens rather than cost. For the agent-wide rate and the budgets → Budgets and limits.
Every key in the table above is read by its own regular expression, and nothing inspects what is left over. A line the parser does not recognise is not reported and not applied — the setting simply keeps its default. The block below parses, runs, prints no warning, and discards all three of these lines:
model Primary {
provider: "openai-compatible"
base_url: "https://gateway.example/v1"
model: "text-model-l"
api_key: getEnv("api_key")
max_token: 100 // dropped — one letter short of the real key
cache_ttll: 5000 // dropped
multimodel: true // dropped — the flag stays false
}This is worth stating because it is not the habit the rest of the language teaches. An agent, datasource, query or command block checks every key: line against its known set and refuses an unknown one by name, before the program starts: Agent 'A' has no key 'retryy'; known keys: role, model, temperature, … A model block has no such check, and neither does run_policy — there an unknown key is dropped as well, while a known key used with the wrong strategy is still refused (RunPolicy 'R' tie can only be used with the vote strategy.).
The boolean flags are where this hurts most, because a dropped flag is indistinguishable from one you never wrote. multimodel: true leaves multimodal at false, and the image and file markers in the prompt then go out exactly as written — base64 payload and all — as plain text: tokens spent, image unseen, no diagnostic anywhere. Treat the table above as the allowlist, and read a model that behaves as though a setting were absent as a spelling question first.
If the agent has no model setting, or it is given a raw model identifier in quotes (model: "org/text-model-s"), the connection is built from the base_url / api_key / model values in .env and the provider is assumed to be openai-compatible. In new projects a named profile is preferred — the capability probe, the cache and run policies only work with profiles.
Provider adapters
provider value | Adapter | Endpoint | Auth header |
|---|---|---|---|
openai-compatible · openai · gateway · deepseek | OpenAI Chat Completions | {base_url}/chat/completions | Authorization: Bearer … |
anthropic | Anthropic Messages | {base_url}/messages | x-api-key + anthropic-version: 2023-06-01 |
gemini · google | Gemini generateContent | {base_url}/v1beta/models/{model}:generateContent | x-goog-api-key |
azure · azure-openai | Azure OpenAI | {base_url}/chat/completions | api-key |
If base_url already contains the endpoint path (e.g. …/chat/completions) it is not appended a second time; the query string is preserved. An unknown provider name is rejected at parse time, before any request is made: Error: Model 'M' has unknown provider '…'. Valid values: …
Tool call mapping
| Adapter | Tool declaration | Model → call | Runtime → result |
|---|---|---|---|
| OpenAI / Azure | tools[].function | tool_calls[] | a role: "tool" message |
| Anthropic | tools[].input_schema | tool_use block | a user message containing tool_result |
| Gemini | functionDeclarations | functionCall | functionResponse |
Structured output support
| Adapter | How the schema is passed to the provider |
|---|---|
| OpenAI / Azure | response_format: { type: "json_schema", json_schema: { strict: true, schema } } |
| Gemini | generationConfig.responseMimeType: "application/json" + responseSchema |
| Anthropic | The schema is declared as a single tool (tools[0].input_schema) and required with tool_choice: { type: "tool", name }; the tool_use.input that comes back is the structured output. Applies only when the agent declares no tools of its own — otherwise the native tool loop takes over and the schema travels in the system message. |
Strict json_schema mode refuses oneOf outright and demands a root of type: "object". A tagged union is therefore emitted as anyOf — the members are already disjoint by their const tag, so nothing is lost — and the OpenAPI-only discriminator keyword is not sent at all, because no provider reads it and strict mode rejects it.
When the ask targets the union itself, the request is wrapped in a single-field envelope named after the union, and the response is unwrapped before validation. Your program never sees the envelope; it exists so the provider's "root must be an object" rule stays out of your schemas.
This is emitted the same way for every adapter rather than branched per provider, so the interpreter and the transpiled artifact stay byte-identical. Reported from the field as #96 — until v0.5.0 the old oneOf emission meant every tagged-union ask was rejected with a 400 by a strict provider.
When an agent declares its own tools, the schema-as-tool trick is not available and the format falls back to the system message plus Luma's local validation. A value such as retry: 2 feeds the validation error back to the model and closes most of that gap.
How the request is shaped
The body sent for every ask is built with these rules:
| Field | Source / rule |
|---|---|
model | The profile's model value. |
messages | system (schema + context instruction + role) → memory history → user (prompt). |
temperature | The agent's temperature; 0 when it is not given. |
max_tokens | The agent's max_tokens, otherwise the profile value; if neither exists it is not sent. |
stream | The profile's stream value. |
reasoning_effort | "medium" when the profile has reasoning: true. |
tools + tool_choice | Present when the agent's tools list is non-empty. tool_choice is "auto" unless the ask carries using tool, which pins it to that one tool. |
response_format | On every request when there are no tools; when there are tools, only after at least one tool call has happened. |
Correlation headers are sent on every request: x-luma-run-id → logical_run_id (the logical run) x-luma-attempt-id → attempt_id (retry / policy candidate) x-luma-request-id → local_request_id (a single HTTP request) The provider ID read back from the response: x-request-id · request-id · anthropic-request-id → provider_request_id
These values are written into telemetry events as well, which lets you line a trace up with your gateway logs. → Correlation
Response handling
raw response (JSON or SSE)
↓ adapter normalization → { content, refusal, toolCalls, usage }
↓ refusal present → MODEL_REFUSAL (retryable: false)
↓ toolCalls present → run the tools, append the result, back to the loop
↓ content empty → MODEL_EMPTY_CONTENT (retryable)
↓ ```json fences are stripped
↓ JSON.parse fails → MODEL_INVALID_RESPONSE (retryable)
↓ local schema validation fails → MODEL_INVALID_RESPONSE (retryable)
↓ resultIf the model wraps its response inside a ```json … ``` block the fences are stripped automatically. Use LUMA_DEBUG_MODEL_RESPONSE=1 to see the raw response.
Capability probe
Before the program starts, Luma sends a request to the gateway's /models endpoint for every model that will be used and verifies that the model supports the capabilities it needs.
| Capability | When it is looked for |
|---|---|
json_schema | For every agent that has an LLM method and for every top-level ask. |
tool_calling | When the agent's tools list is non-empty. |
streaming · reasoning · multimodal | When the corresponding flag is on in the profile. |
capabilities in the profile | Every listed value is verified as well. |
| Situation | warn default | strict |
|---|---|---|
| The gateway is unreachable | Prints a warning, carries on | The program stops |
The model is absent from the /models response | Prints a warning, carries on | The program stops |
| Capability information is missing | Prints a warning, carries on | The program stops |
The capability is explicitly false | stops | stops |
{
"model_runtime": {
"capability_policy": "strict",
"capability_probe_timeout_ms": 5000
}
}export LUMA_CAPABILITY_POLICY=strict export LUMA_CAPABILITY_PROBE_TIMEOUT_MS=3000
Environment variables override the config. The probe timeout defaults to 5000 ms and can be cancelled with Ctrl-C.
Warning: 'text-model-l' has no json_schema capability information.
Most gateways do not return a capabilities object in the /models response; this warning is normal and does not block the program. If you want strict checking, switch to strict mode — but make sure your gateway really does report that information.
Streaming, reasoning, multimodal
model VisionReasoner {
provider: "openai-compatible"
base_url: "https://gateway.example/v1"
model: "vision-model"
api_key: getEnv("api_key")
stream: true
reasoning: true
multimodal: true
}| Flag | Effect |
|---|---|
stream: true | The request is made as SSE; the chunks (content, tool call arguments, usage) are collected and the response is returned in full. Luma does not emit output piece by piece — the streaming exists only in the transport layer. |
reasoning: true | reasoning_effort: "medium" is added to the request. Reasoning tokens are reported in telemetry as reasoningTokens. |
multimodal: true | The {{image_url:…}} and {{file_url:…}} markers inside the prompt are converted into content parts. When the flag is off the markers go out as plain text. |
For multimodal details and the file pipeline → IO & Multimodal.
Model response cache
model Cached {
provider: "openai-compatible"
base_url: "https://gateway.example/v1"
model: "text-model-l"
api_key: getEnv("api_key")
cache: true
cache_ttl: 60000
cache_max_entries: 256
}| Property | Behavior |
|---|---|
| Scope | Only on profiles with cache: true and only when the request carries no tool declarations. |
| Key | provider + base_url + model + the canonical JSON of the request. The API key does not enter the key. |
| What is stored | Only the schema-valid final result. |
| Eviction | When the TTL expires and when cache_max_entries is exceeded (LRU). |
| Single-flight | Concurrent requests for the same key share a single call. |
Run policy: multi-model strategies
run_policy determines how an ask call is executed across more than one model. It is attached to an agent and covers all of that agent's LLM calls.
run_policy Resilient {
strategy: "fallback"
models: [Primary, Backup]
on: ["timeout", "rate_limit", "provider_error", "empty_content"]
max_attempts: 2
}
agent Analyst {
model: Primary
run_policy: Resilient
}Strategies
| Strategy | Behavior | Model count |
|---|---|---|
single | A single model. The same as giving no policy at all; retry works normally. | 0–1 |
fallback | Tries them in order; moves on to the next one only for the error kinds in the on list. | ≥ 1 |
escalate | A ladder from the cheapest model to the strongest: it climbs a rung only for the error kinds in the on list, and stops the moment one model answers. max_cost_per_call caps what a single call may spend. | ≥ 2 |
parallel | Runs them all concurrently. select: "first" returns the first success, "all" returns every success. | ≥ 1 |
race | Starts them all, takes the first successful result and cancels the others. | ≥ 1 |
vote | Votes on the results through their canonical JSON; looks for a quorum. | ≥ 2 |
fusion | Hands the successful candidates to a synthesizer model and has it produce one final result. | ≥ 1 + synthesizer |
combo | Runs the models as a sequential pipeline; each stage improves the output of the one before it. | ≥ 2 |
Configuration keys
| Key | Which strategies | Default |
|---|---|---|
models | required in all of them except single | — |
on | all (in practice fallback and escalate) | ["retryable"] |
max_attempts | fallback only | the model count |
max_models | parallel · race · vote · fusion | the model count |
max_stages | combo only | the model count |
max_cost_per_call | escalate only | unlimited |
quorum | vote only | floor(n/2) + 1 |
tie | vote only | fail |
select | parallel only | first |
synthesizer | fusion only (required) | — |
synthesizer_policy | fusion only | deny_tools |
max_fusion_inputs · fusion_input_tokens | fusion only | unlimited |
Using a key with the wrong strategy is a parse error — tie, for example, is valid only for vote.
on: which errors bring it into play
The valid trigger values:
retryable timeout rate_limit provider_error empty_content invalid_response refusal invalid_tool_call cancelled unsupported budget_exceeded raw error codes can also be written: MODEL_TIMEOUT · MODEL_RATE_LIMIT · MODEL_PROVIDER_ERROR · …
"retryable" is a special value: it looks at the error's retryable flag, not at its code. An unrecognized trigger is a parse error.
Strategy details
fallback
run_policy Resilient {
strategy: "fallback"
models: [Primary, Backup, Reserve]
on: ["timeout", "rate_limit", "provider_error"]
max_attempts: 2
}The models are tried in order. If the error does not match the on list it is thrown right away — so a validation failure does not needlessly push the call onto a second model. max_attempts limits how many models are tried.
escalate
run_policy Adaptive {
strategy: "escalate"
models: [Cheap, Strong]
on: ["invalid_response", "empty_content"]
max_cost_per_call: 0.02
}
agent Extractor {
model: Cheap
run_policy: Adaptive
retry: 1
cost_budget: 0.5
}The models are a ladder from the cheapest to the strongest and one rung runs at a time. A model is reached only when the model before it failed with a trigger from the on list; an error outside that list goes to the caller unchanged. When the cheap model answers, the strong model is not called at all — that is what separates escalate from combo, which runs every stage whether or not the previous one succeeded. At least two models are required: Escalate run policy 'Adaptive' must specify at least two models, from the cheapest to the strongest.
retryis the inner loop, escalation the outer one. A stage spends its retries on the model that is running — the cheap model corrects itself first — and only then is a stronger model paid for. The policy above makes two requests toCheapbefore it ever reachesStrong. Every other multi-model strategy keeps running its candidates withretry: 0.max_cost_per_callis a ceiling on a single policy call. It is checked after every stage against one ledger shared by the whole ladder, and it is inclusive: a call may spend exactly the ceiling, but is then left with nothing to climb with.- Reaching the ceiling stops the climb, and the model that was not bought is named:
MODEL_BUDGET_EXCEEDED·RunPolicy 'Adaptive' spent 0.03 of its max_cost_per_call of 0.02, so it did not escalate to model 'Strong'. - Exceeding the ceiling means the result is not returned either, valid or not — the overrun is reported rather than swallowed:
MODEL_BUDGET_EXCEEDED·RunPolicy 'Adaptive' spent 0.03 on this call, above its max_cost_per_call of 0.02. - Every stage is priced with the
cost_per_1k_tokensof the model profile that served it, falling back to the agent's rate. Underescalatethat ledger spans the whole ladder instead of restarting at each rung, so the agent'scost_budget/token_budgetcan end the climb as well:Agent cost budget exceeded (0.5).With no price anywhere the cost is computed as0and no ceiling ever fires.
The escalation triggers are the same on list every other strategy uses — one concept, one key. A policy that writes escalate_on is not quietly ignored, it is rejected at parse time by name: RunPolicy 'Adaptive' has no escalate_on key; the escalate strategy reads its escalation triggers from the on list. In the same way max_cost_per_call belongs to escalate alone (RunPolicy 'Adaptive' max_cost_per_call can only be used with the escalate strategy.) and a ceiling of 0 is refused (RunPolicy 'Adaptive' strategy limit must be positive.).
parallel
run_policy Fanout {
strategy: "parallel"
models: [Primary, Backup]
select: "all"
}
agent Analyst {
run_policy: Fanout
fn generate() -> Candidate[] {
ask "Generate candidate results" -> Candidate[]
}
}Every LLM method of an agent bound to a policy that uses select: "all" must return Schema[]. Otherwise you get a parse error: Agent 'X' uses select: "all", so the LLM method must return schema[].
race
run_policy Fastest {
strategy: "race"
models: [Primary, Backup]
}The first successful result wins; the remaining requests are cancelled with AbortSignal. If all of them fail, the first error is thrown.
vote
run_policy Consensus {
strategy: "vote"
models: [Primary, Backup, Reviewer, Judge]
quorum: 2
tie: "highest_confidence"
}- Only schema-valid results are voted on; they are compared through their canonical JSON (keys sorted).
- If the quorum is not reached:
MODEL_VOTE_NO_QUORUM(retryable: true). - Ties:
fail→MODEL_VOTE_TIE;first→ takes the first one;highest_confidence→ looks at the numericconfidencefield in the results. - If
highest_confidenceis chosen and the results carry noconfidence:MODEL_VOTE_CONFIDENCE_UNAVAILABLE.
Because voting looks for exact JSON equality, a schema containing free-text fields almost never reaches a quorum. Use vote on narrow schemas: enum classification, a boolean decision, a short code field. If you are going to use tie: "highest_confidence", add confidence: float [0..1] to the schema.
fusion
run_policy SafeFusion {
strategy: "fusion"
models: [Primary, Backup, Reviewer]
synthesizer: Primary
max_fusion_inputs: 3
fusion_input_tokens: 4000
synthesizer_policy: "deny_tools"
}The candidates run concurrently; the successful ones are handed to the synthesizer model as "candidate outputs" and the final schema-valid result is produced.
- If there is no successful candidate:
MODEL_FUSION_NO_INPUTS. - If the estimated input tokens exceed
fusion_input_tokens:MODEL_BUDGET_EXCEEDED. synthesizer_policy: "deny_tools"default gives the synthesizer no tools and rejects write tools;"inherit"passes the agent's tools through.- The synthesizer prompt explicitly tells the model to treat the candidates as data and not to carry out the instructions inside them.
combo
run_policy Pipeline {
strategy: "combo"
models: [Draft, Reviewer, Finalizer]
max_stages: 3
}The models run in sequence. Each stage's schema-valid output is added to the next stage's prompt as "the previous stage's output, improve it". The result of the last stage is returned.
In every strategy other than single and escalate each candidate runs with retry: 0 — resilience comes from the policy itself rather than from retry. escalate is the exception: there retry is the inner loop and a stage exhausts it before the ladder moves on. If you want to use retry and a policy together, escalate, or the fallback strategy with its on list, is the right tool.
Which strategy when?
| Goal | Strategy |
|---|---|
| Resilience against a gateway outage | fallback |
| The lowest latency | race |
| Confidence in a critical classification | vote |
| Merging several points of view | fusion |
| A cheap model drafts, an expensive model polishes | combo |
| Paying for the strong model only when the cheap one fails | escalate + max_cost_per_call |
| Collecting many candidates for the same task | parallel + select: "all" |
| Cost control | single + retry + token_budget |