llm
Calls large language models from L4: Anthropic's Claude, OpenAI, or any model on OpenRouter. Import it with IMPORT llm.
Each call sends one user message and gives back the provider's reply exactly as it arrives: the response body, a JSON text in a STRING. Take the answer out of it with JSONDECODE (see Reading the reply).
Example:
-- llm example: a model answers a question of judgment, an L4 rule decides.
IMPORT prelude
IMPORT llm
-- The part of an Anthropic reply this file needs.
-- JSONDECODE ignores the other fields.
DECLARE `reply block` HAS
text IS A STRING
DECLARE `Anthropic reply` HAS
content IS A LIST OF `reply block`
GIVEN `raw reply` IS A STRING
GIVETH A MAYBE STRING
`the text of` `raw reply` MEANS
CONSIDER `decoded reply`
WHEN RIGHT reply THEN
CONSIDER reply's content
WHEN EMPTY THEN NOTHING
WHEN block FOLLOWED BY rest THEN JUST (block's text)
WHEN LEFT problem THEN NOTHING
WHERE
GIVETH AN EITHER STRING `Anthropic reply`
`decoded reply` MEANS JSONDECODE `raw reply`
-- The question put to the model.
GIVEN `the notice` IS A STRING
GIVETH A STRING
`the question about` `the notice` MEANS
CONCAT "Answer YES or NO and nothing else. Is this notice written in a professional tone? ", `the notice`
-- Asks Claude. This needs ANTHROPIC_API_KEY and network access,
-- so the file defines it but does not evaluate it.
GIVEN `the notice` IS A STRING
GIVETH A MAYBE STRING
`the verdict of the model on` `the notice` MEANS
CONSIDER ENV "ANTHROPIC_API_KEY"
WHEN JUST key THEN `the text of` (callClaude key (`the question about` `the notice`))
WHEN NOTHING THEN NOTHING
-- The rule takes the model's answer as an input,
-- so it can be tested without calling a model.
GIVEN `the notice was given in time` IS A BOOLEAN
`the verdict of the model` IS A MAYBE STRING
GIVETH A BOOLEAN
DECIDE `the notice is valid` IF
`the notice was given in time`
AND `the verdict of the model` EQUALS JUST "YES"
#EVAL `the text of` "{\"id\":\"msg_1\",\"content\":[{\"type\":\"text\",\"text\":\"YES\"}]}"
#EVAL `the notice is valid` TRUE (JUST "YES")
#EVAL `the notice is valid` TRUE NOTHING
Configuration
- API keys. The
call...functions take the key as their first input; read it with the built-inENVso it stays out of your file.queryLLMandqueryLLMWithDefaultsread the environment variablesOPENROUTER_API_KEY,OPENAI_API_KEYandANTHROPIC_API_KEYthemselves. - Network. The calls use the built-in
POST, so every evaluation makes a real HTTPS request and needs network access. Where L4 runs in safe mode, as it does in the browser, the request is refused with an error. See HTTP and JSON.
Functions
| Function | Inputs | Gives | What it does |
|---|---|---|---|
callAnthropic |
apiKey, model, prompt: STRING, maxTokens: NUMBER |
STRING | Calls Anthropic's Messages API |
callOpenAI |
apiKey, model, prompt: STRING, maxTokens: NUMBER |
STRING | Calls OpenAI's Chat Completions API |
callOpenRouter |
apiKey, model, prompt: STRING, maxTokens: NUMBER |
STRING | Calls OpenRouter's chat completions API |
callClaude |
apiKey, prompt: STRING |
STRING | callAnthropic with the model claude-sonnet-4-5-20250929 and 1000 tokens |
callGPT4 |
apiKey, prompt: STRING |
STRING | callOpenAI with the model gpt-4 and 1000 tokens |
queryLLM |
model, prompt: STRING, maxTokens: NUMBER |
STRING | Calls the first provider whose key is set, trying OpenRouter, then OpenAI, then Anthropic |
queryLLMWithDefaults |
prompt: STRING |
STRING | The same order, with each provider's default model and 1000 tokens |
parseResponse |
jsonString: STRING |
EITHER STRING a | JSONDECODE of the text |
createMessage |
role, content: STRING |
Message |
A Message record, as sent in the request |
queryLLM passes the same model name to whichever provider it calls, and the providers name models differently (OpenRouter writes anthropic/claude-3.5-sonnet), so give it a name the provider you expect will accept.
When no key is set, queryLLM and queryLLMWithDefaults do not fail: they give a STRING that begins ERROR: No LLM API keys configured.
The defaults of queryLLMWithDefaults are anthropic/claude-3.5-sonnet on OpenRouter, gpt-4 on OpenAI and claude-sonnet-4-5-20250929 on Anthropic.
Model names
These constants name models the way OpenRouter does, for callOpenRouter. They name older models; you can pass any current model name as a STRING instead.
| Constant | Value |
|---|---|
claudeOpusModel |
"anthropic/claude-3-opus" |
claudeSonnetModel |
"anthropic/claude-3.5-sonnet" |
gpt4Model |
"openai/gpt-4" |
gpt4TurboModel |
"openai/gpt-4-turbo" |
gpt35Model |
"openai/gpt-3.5-turbo" |
llama3Model |
"meta-llama/llama-3.1-70b-instruct" |
llama3FreeModel |
"meta-llama/llama-3.1-70b-instruct:free" |
geminiProModel |
"google/gemini-pro" |
Reading the reply
The reply is the provider's JSON. Declare records for the fields you need and decode into them with JSONDECODE; fields you do not declare are ignored.
- Anthropic puts the text in
content, a list of blocks that each have atextfield:{"content": [{"type": "text", "text": "..."}]}. - OpenAI and OpenRouter put it in
choices, a list whose items have amessagewith acontentfield:{"choices": [{"message": {"content": "..."}}]}.
The example file decodes an Anthropic reply in `the text of`.
Keeping judgment and rules apart
A model is good at questions of judgment, such as whether a notice is written in a professional tone. L4 is good at applying rules to the answers. Keep the two apart:
- Write the rule with the model's answer as an input (
GIVEN), not as a call inside it. The rule can then be tested with#EVALby passing answers directly, and it reads the same whoever, or whatever, supplies the answer. - Ask for an answer in a fixed form, such as
YESorNO, so the rule can compare it. - A model can give different answers to the same prompt. If decisions must be audited, keep the prompt, the model name, the reply and the time of the call alongside the decision.