Providers Guide
LLM4S supports multiple LLM providers out of the box. Choose your provider, configure it, and start building.
Table of contents
- Supported Providers
- Provider Selection
- OpenAI
- Anthropic
- Google Gemini
- Google Vertex AI
- Provider-specific keys
- IBM watsonx.ai
- Perplexity
- Azure OpenAI
- DeepSeek
- OpenRouter and Z.ai
- OpenAI-compatible endpoints
- Mistral
- Cohere
- xAI (Grok)
- Ollama (Local Models)
- Thinking in conversation history
- Citations and grounding sources
- Enterprise authentication
- API Key Management
- Base URL Customization
- Provider Comparison Table
- Multiple Providers in One App
- Troubleshooting
Supported Providers
LLM4S supports these LLM providers, plus any endpoint that speaks the OpenAI chat-completions API. What each one supports with LLM4S - chat, streaming, tool calling, embeddings, API key or workload identity, live smoke suites - is in the compatibility matrix, generated from the provider modules’ code and checked in CI, so this table does not repeat it:
| Provider | provider id |
Type | Models | Setup |
|---|---|---|---|---|
| OpenAI | openai |
Cloud | GPT and o-series models | Medium |
| Anthropic | anthropic |
Cloud | Claude models | Medium |
| Google Gemini | gemini |
Cloud | Gemini models, with a free tier | Medium |
| Google Vertex AI | vertexai |
Cloud Enterprise | Gemini models in your own GCP project | Medium |
| Azure OpenAI | azure |
Cloud Enterprise | OpenAI models in your Azure deployment | Hard |
| DeepSeek | deepseek |
Cloud | DeepSeek models | Easy |
| OpenRouter | openrouter |
Cloud gateway | Many vendors’ models behind one key | Easy |
| Z.ai | zai |
Cloud | GLM models | Easy |
| OpenAI-compatible | openai-compatible |
Cloud or local | Groq, Together, Fireworks, NVIDIA NIM, OrcaRouter; vLLM, LM Studio, llama.cpp - config only (recipes) | Easy |
| Mistral | mistral |
Cloud | Mistral and Magistral models | Easy |
| Cohere | cohere |
Cloud | Command models | Easy |
| xAI | xai |
Cloud | Grok models | Easy |
| Ollama | ollama |
Local | Open models on your machine, no API key | Easy |
| IBM watsonx.ai | watsonx |
Cloud Enterprise | Granite, Llama and Mistral models under IBM governance | Medium |
| Perplexity | perplexity |
Cloud | Agent API: web-search answers with citations, from Perplexity’s and other vendors’ models (llm4s-perplexity) |
Easy |
| Amazon Bedrock | bedrock |
Cloud Enterprise | Models on AWS, through the Converse API (llm4s-bedrock) |
Medium |
| Requesty | requesty |
Cloud gateway | Many vendors’ models behind one key | Easy |
Missing a vendor? See Writing a Provider to publish your own provider module.
Provider Selection
How It Works
Each provider you use is a named section under llm4s.providers in your own
application.conf. llm4s.providers.provider names the default section, which
Llm4sConfig.defaultProvider() loads; Llm4sConfig.provider("<name>") loads any section by name.
A section’s API key comes from its own apiKey when it sets one, and otherwise from its
vendor’s shared key, llm4s.credentials.<provider>.apiKey, which each provider module binds to
the vendor’s conventional variable (OPENAI_API_KEY, ANTHROPIC_API_KEY, …). So with the
variable exported, a section needs only provider and model. The library reads no LLM_MODEL.
See API keys for the full table and for a
second account.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# src/main/resources/application.conf
llm4s {
providers {
provider = "openai-main" # the default: the name of a section below
openai-main { # key from OPENAI_API_KEY
provider = "openai"
model = "gpt-4o-mini"
}
claude { # key from ANTHROPIC_API_KEY
provider = "anthropic"
model = "claude-sonnet-4-20250514"
}
}
}
1
2
3
4
import org.llm4s.config.Llm4sConfig
val default = Llm4sConfig.defaultProvider() // openai-main
val claude = Llm4sConfig.provider("claude") // any section, by name
Only the section being loaded is validated, so a section with no key available - or whose
provider module is not on the classpath - fails only when it is loaded: with the file above and
just OPENAI_API_KEY set, defaultProvider() succeeds and provider("claude") fails with an
error naming ANTHROPIC_API_KEY and claude’s apiKey. See
Named provider sections and
Switching providers for the full story.
Available Models
See MODEL_METADATA.md for the complete model list. Quick reference:
OpenAI: gpt-4o, gpt-4-turbo, gpt-3.5-turbo
Anthropic: claude-opus-4-6, claude-sonnet-4-5-latest, claude-haiku-3-5
Google Gemini: gemini-2.0-flash, gemini-1.5-pro, gemini-1.5-flash
DeepSeek: deepseek-chat, deepseek-reasoner
Mistral: mistral-large-latest, mistral-small-latest, magistral-medium-latest
Cohere: command-a-03-2025
Ollama: mistral, llama2, neural-chat, nomic-embed-text (100+ models)
OpenAI
From the release after 0.4.1, OpenAI support - chat, embeddings, and the Azure OpenAI and
Requesty providers that share its client - ships in its own artifact, alongside llm4s-core;
adding it registers the providers:
1
libraryDependencies += "org.llm4s" %% "llm4s-openai" % llm4sVersion
In 0.4.1 and earlier it is part of llm4s-core. See the
installation guide.
Setup
- Get an API key from platform.openai.com/api-keys
- Add a named section to
application.confand select it as the default (below). - Export the key -
llm4s-openaibinds it tollm4s.credentials.openai.apiKey, which the section uses:
1
export OPENAI_API_KEY=sk-proj-...
Configuration
In application.conf:
1
2
3
4
5
6
7
8
9
10
11
12
13
llm4s {
providers {
provider = "openai-main"
openai-main {
provider = "openai"
model = "gpt-4o"
# apiKey = ${?OPENAI_BATCH_API_KEY} # optional; default: OPENAI_API_KEY
organization = ${?OPENAI_ORGANIZATION} # optional, for multi-workspace accounts
# baseUrl = "https://my-proxy.example.com/v1" # optional; default https://api.openai.com/v1
}
}
}
Without an apiKey line the section uses OPENAI_API_KEY. Set apiKey for a second account
or a variable name of your own; it wins over the shared key. OPENAI_ORGANIZATION is read only
because the section binds it.
Available Models
- Latest:
gpt-4o,gpt-4o-mini - Reasoning: the gpt-5 family (
gpt-5,gpt-5-mini,gpt-5.1, …) and the o-series (o3,o4-mini) - Turbo:
gpt-4-turbo - Legacy:
gpt-3.5-turbo
Reasoning models
CompletionOptions.reasoning is sent as reasoning_effort to OpenAI’s reasoning models:
1
2
3
4
import org.llm4s.llmconnect.model.{ CompletionOptions, ReasoningEffort }
// client: LLMClient, conversation: Conversation
client.complete(conversation, CompletionOptions().withReasoning(ReasoningEffort.High))
ReasoningEffort |
reasoning_effort |
|---|---|
Low / Medium / High |
low / medium / high |
None |
not sent: the model’s own default applies |
Which models are reasoning models comes from the model registry’s supports_reasoning flag
(the same metadata llm4s uses for context windows and costs), so the o-series and the gpt-5
family get it, and gpt-4o, gpt-4.1 and older models never do: OpenAI rejects the parameter
for them, so for those models reasoning is ignored, as CompletionOptions documents. A model
newer than the bundled metadata is recognised by its name (o<n>..., gpt-5 onwards,
gpt-oss). For Requesty, a routed id such as openai/o4-mini resolves the same way; a
non-OpenAI model behind Requesty is not sent reasoning_effort. A fine-tuned model is judged by
the model it was trained from: ft:o4-mini-2025-04-16:my-org:my-suffix:abc123 is treated as
o4-mini, and a fine-tune of gpt-4o-mini as gpt-4o-mini.
Requests to a reasoning model also follow OpenAI’s rules for them: maxTokens is sent as
max_completion_tokens (which counts reasoning tokens too) rather than max_tokens, and
temperature, topP, presencePenalty and frequencyPenalty are not sent, since those models
reject non-default values. (gpt-oss models keep their sampling parameters.)
The reasoning tokens a response reports (completion_tokens_details.reasoning_tokens) are
returned as TokenUsage.thinkingTokens, for streamed and non-streamed calls. They are part of
completionTokens, not in addition to it. OpenAI’s chat-completions API does not return the
reasoning text itself, so Completion.thinking stays empty.
Streaming and token usage
Streaming requests set stream_options.include_usage, so OpenAI reports token usage on a final
chunk with no choices, and a streamed Completion carries usage and estimatedCost like a
non-streamed one. The final chunk produces no StreamedChunk.
Costs
See OpenAI Pricing. Generally:
gpt-4o: $2.50-$10 per 1M input tokensgpt-3.5-turbo: $0.50-$1.50 per 1M input tokens
Tips
- Use
gpt-4o-minifor cost-effective applications - Use
o1for complex reasoning and math - Batching API available for high-volume use
- Vision support in
gpt-4o
Anthropic
From the release after 0.4.1, Anthropic support ships in its own artifact, alongside
llm4s-core; adding it registers the provider:
1
libraryDependencies += "org.llm4s" %% "llm4s-anthropic" % llm4sVersion
In 0.4.1 and earlier it is part of llm4s-core. See the
installation guide.
Setup
- Get an API key from console.anthropic.com
- Add a named section to
application.confand select it as the default (below). - Export the key -
llm4s-anthropicbinds it tollm4s.credentials.anthropic.apiKey:
1
export ANTHROPIC_API_KEY=sk-ant-...
Configuration
In application.conf:
1
2
3
4
5
6
7
8
9
10
11
12
llm4s {
providers {
provider = "anthropic-main"
anthropic-main {
provider = "anthropic"
model = "claude-opus-4-6"
# apiKey = ${?ANTHROPIC_TEAM_API_KEY} # optional; default: ANTHROPIC_API_KEY
# baseUrl = "https://my-proxy.example.com" # optional; default https://api.anthropic.com
}
}
}
Available Models
- Best Quality:
claude-opus-4-6(200K context) - Balanced:
claude-sonnet-4-5-latest(200K context) - Fast:
claude-haiku-3-5(200K context)
Costs
claude-opus-4-6: $3-$15 per 1M input tokensclaude-sonnet: $3-$15 per 1M input tokensclaude-haiku: $0.80-$4 per 1M input tokens
Claude models generally score higher on reasoning benchmarks.
Tips
- All Claude models have 200K context window
- Exceptional at writing and analysis tasks
- Excellent vision capabilities
- Supports prompt caching for repeated queries
Web search
Set webSearch = true in an anthropic section and every request carries Anthropic’s server-side web search
tool (web_search_20250305). The model then decides per request whether to search, and the sources its answer
cites arrive on Completion.citations (see Citations and grounding sources):
1
2
3
4
5
llm4s.providers.claude-search {
provider = "anthropic"
model = "claude-sonnet-4-5"
webSearch = true
}
It is off by default. Anthropic bills each search on top of tokens, and an organization administrator has to
allow web search in the Console first. The tool is sent beside your own tools; Anthropic runs the search itself,
so it never reaches a ToolRegistry and the completion’s toolCalls do not include it.
Google Gemini
From the release after 0.4.1, Gemini (and Vertex AI) support ships in its own artifact,
alongside llm4s-core; adding it registers both providers:
1
libraryDependencies += "org.llm4s" %% "llm4s-gemini" % llm4sVersion
In 0.4.1 and earlier they are part of llm4s-core. See the
installation guide.
Setup
- Get an API key from aistudio.google.com/apikey
- Free tier available (60 requests per minute)
- Add a named section to
application.confand select it as the default (below). - Export the key -
llm4s-geminibindsGOOGLE_API_KEYandGEMINI_API_KEYtollm4s.credentials.gemini.apiKey, preferringGOOGLE_API_KEYwhen both are set, as Google’s SDKs do. Aprovider = "google"section uses the same key.
1
export GEMINI_API_KEY=your-api-key
Configuration
In application.conf:
1
2
3
4
5
6
7
8
9
10
11
12
llm4s {
providers {
provider = "gemini-main"
gemini-main {
provider = "gemini" # "google" is accepted as an alias
model = "gemini-2.0-flash"
# apiKey = ${?GEMINI_TEAM_API_KEY} # optional; default: GOOGLE_API_KEY or GEMINI_API_KEY
# baseUrl = "..." # optional; default https://generativelanguage.googleapis.com/v1beta
}
}
}
Available Models
- Latest:
gemini-2.0-flash(1M context) - Advanced:
gemini-1.5-pro(1M context) - Fast:
gemini-1.5-flash(1M context)
Costs
- Free tier: 60 requests/minute, 2M free tokens/month
- Paid: Pay as you go (~$0.075-$1.50 per 1M input tokens)
Great for cost-conscious projects and high-volume applications.
Tips
- Free tier perfect for development and testing
- 1M context window for processing large documents
- Very fast inference latency
- Strong code generation capabilities
Google Search grounding
Set googleSearch = true in a gemini or vertexai section and every request carries Gemini’s Google
Search tool (google_search; googleSearch on Vertex AI). The model then decides per request whether to
search, and the sources behind a grounded answer arrive on Completion.citations (see
Citations and grounding sources):
1
2
3
4
5
llm4s.providers.gemini-search {
provider = "gemini"
model = "gemini-2.5-flash"
googleSearch = true
}
It is off by default. Google bills grounded requests separately, and the citation URLs it returns are
vertexaisearch.cloud.google.com redirects, not the pages themselves. The tool is sent beside any function
declarations; a model that does not support search together with function calling rejects that request.
Google Vertex AI
Vertex AI serves the same Gemini models from a GCP project, authenticated with OAuth2 rather
than an API key. It ships in llm4s-gemini beside the Gemini API provider, so the dependency
above registers both.
Setup
- Pick a GCP project with the Vertex AI API enabled, and a region that serves your model.
- Authenticate with Application Default Credentials (
gcloud auth application-default login), or pointapiKeyat a service-account JSON file. WithoutapiKey, credentials come fromGOOGLE_APPLICATION_CREDENTIALS, then gcloud’sapplication_default_credentials.json, then the GCE metadata server. The file is located as Google’s client libraries do: in$CLOUDSDK_CONFIGwhen that is set (any OS), else%APPDATA%\gcloudon Windows, else~/.config/gcloud.
Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
llm4s {
providers {
provider = "vertex-main"
vertex-main {
provider = "vertexai" # "vertex" is accepted as an alias
model = "gemini-2.0-flash"
project = ${?VERTEXAI_PROJECT}
location = "europe-west4" # optional
apiKey = ${?GOOGLE_APPLICATION_CREDENTIALS} # optional: path to a credential file
}
}
}
| Key | Required | Meaning |
|---|---|---|
project |
yes | The GCP project ID that owns your Vertex AI resources |
location |
no | The GCP region; requests go to https://<location>-aiplatform.googleapis.com/v1. Default us-central1 |
apiKey |
no | A path to a service-account credential file - not an API key |
Because Vertex AI has no API key, it has no shared llm4s.credentials key: nothing is bound for
it, and GOOGLE_API_KEY / GEMINI_API_KEY apply to the Gemini API provider only.
project and location are Vertex AI’s own keys (provider-specific keys).
Before #1215 Vertex read the project from endpoint
and the region from organization. Those still work as deprecated aliases, with a warning each
time the config loads; rename them. Setting both a key and a different value under its old name
is an error. See the migration note.
Provider-specific keys
Every named section shares the built-in fields - provider, model, baseUrl, apiKey and
headers - and a provider may declare keys of its own. Its section of this guide lists them:
| Provider | Its own keys |
|---|---|
| OpenAI, Requesty, OpenRouter | organization |
| Azure OpenAI | endpoint (required), apiVersion, authentication, clientId |
| Vertex AI | project (required), location |
Generic openai-compatible |
contextWindow, reserveCompletion, registryProvider, streamUsage |
| Perplexity | webSearch, contextWindow, reserveCompletion |
A required one that is missing fails the config with its name, the section and what it means; a
key that is neither built-in nor declared by the section’s provider is ignored with a warning
naming it, so a misspelt key - or organization in an Anthropic section - shows up in the log
rather than silently doing nothing. organization, endpoint, apiVersion, contextWindow and
reserveCompletion were built-in fields that every section carried until
#1133; the HOCON for the providers that use them is
unchanged.
IBM watsonx.ai
Beta, never run against the live service.
llm4s-watsonxcalls the watsonx.ai chat API (/ml/v1/text/chatand/ml/v1/text/chat_stream), which replaces the “Infer text” endpoints IBM deprecated in February 2026. There is no watsonx account to test with, so nothing here has been checked against the real service. The request and response shapes are the ones IBM’s public pages report, cross-checked against IBM’s own open-source clients (the Node.js SDK and IBM’s LangChain integration; the pinned sources are listed inWatsonXClient’s Scaladoc): the token-limit field, the encoding of tool-call arguments, the finish reasons and the stream’s usage chunk are all shown there. IBM’s tool-calling guide shows the same tool request (tool_choice_option: "auto") and tool-call reply, and a test pins its example.WatsonXChatOverHttpSpecruns the client through the real HTTP stack against a local server: tool calls whole and streamed, structured messages with no role-marker prompt, and the error mapping and redaction for a wrong key. That proves IBM’s clients send these shapes, not that the service accepts them today. The module is Beta and its API is not frozen. The@CloudsuiteWatsonXAssumptionProbeSpec(modules/it) checks each of those details against a real account and prints which held; running it is still open in #1314. Until it has been run, treat tool calling and streaming as unconfirmed.
IBM’s enterprise AI platform, serving Granite, Llama and Mistral models. It lives in its own module,
llm4s-watsonx; adding the dependency registers the watsonx provider.
1
libraryDependencies += "org.llm4s" %% "llm4s-watsonx" % llm4sVersion
1
2
3
4
5
6
7
llm4s.providers.watsonx-main {
provider = "watsonx"
model = "ibm/granite-4-h-small" # any chat model your watsonx.ai region serves
projectId = ${?WATSONX_PROJECT_ID} # or spaceId for a deployment space
# baseUrl = "https://eu-de.ml.cloud.ibm.com" # default https://us-south.ml.cloud.ibm.com
# apiVersion = "2026-09-25" # the `version` query parameter; default as shown
}
WATSONX_API_KEY supplies the IBM Cloud API key, which the client exchanges for an IAM bearer token
and refreshes before it expires. The conversation goes as structured messages with roles
(system, user, assistant, tool), so user content is data and cannot forge a turn.
Behaviour to know about:
- Tools.
CompletionOptions.toolsare sent astoolswithtool_choice_option: "auto", a reply’stool_calls(whole, or streamed asdeltapieces) becomeToolCalls, and a tool result goes back as atoolmessage with itstool_call_id. Streamed calls are reported once, whole, after the last delta. Whether a model supports tools depends on the model; a model that does not is answered by the service, and that error is returned as the HTTP status maps it. A call the service sends without anidis given a generated one. - Sampling options.
temperatureis always sent,maxTokens(asmax_tokens) andtopP(when not 1.0) when set.presencePenaltyandfrequencyPenaltygo aspresence_penaltyandfrequency_penaltywhen not 0.0, their default, so leaving them alone sends nothing new; the service checks their range. - Structured output.
responseFormatgoes asresponse_format:ResponseFormat.Jsonas{"type": "json_object"}, andResponseFormat.JsonSchema(schema, name, strict)as{"type": "json_schema", "json_schema": {"name": ..., "schema": ..., "strict": ...}}, the shapes IBM’s Python SDK and LangChain integration send (IBM’s Node.js SDK names onlyjson_object). JSON mode needs the prompt to ask for JSON too; the client adds no instruction. Whether a model honours either format depends on the model. Like the rest of this section, both are unconfirmed until the probe suite runs. - Reasoning.
CompletionOptions.reasoninggoes asreasoning_effort(Low,MediumandHighaslow,mediumandhigh) to OpenAI’s gpt-oss models (openai/gpt-oss-120b,openai/gpt-oss-20band their variants), the models IBM’s own clients send it to. The chat API has no value that turns reasoning off, soReasoningEffort.Nonegoes aslow, and a warning is logged once per process. Any other model is sent nothing forreasoning, again with a warning logged once per process: IBM’s Model Gateway rejects the field for models without it, and whether the chat API does is unconfirmed.include_reasoningis not sent; the service defaults it to true. - Thinking. A reply’s
reasoning_content(orreasoning) is read as thinking from any model:Completion.thinkingoncomplete, andStreamedChunk.thinkingDeltaon a stream, with the whole text on the final completion. It is not sent back on later turns. The chat API reports no reasoning token count, soTokenUsage.thinkingTokensstays empty (IBM’s recorded replies suggest the reasoning is counted incompletion_tokens).budgetTokenshas no counterpart and is ignored. Granite models that put their reasoning inside the content between tags (<think>...</think>) are not split: that text stays incontent. The field names come from IBM’s SDKs and from replies IBM’s Java SDK recorded fromopenai/gpt-oss-120b; like the rest of this section, they are unconfirmed until the probe suite runs. - Abnormal endings. A stream that ends without a
finish_reason, or whosefinish_reasoniserror,cancelledortime_limit, returnsLeft(ServiceError)naming the reason, not the partial text (chunks already passed toonChunkwere delivered).stop,length,tool_callsand unknown reasons are normal stops.completeapplies the same rule tochoices[0].finish_reason. - Stream error events. An
event: errorframe ends the stream with the error IBM sends in itsdata:line: itsstatus_codepicks the error as an HTTP status would (AuthenticationErrorfor 401 and 403,RateLimitErrorfor 429,ValidationErrorfor 400,ServiceErrorotherwise, andServiceError(502)without one), and the message is IBM’s errorcodeandmessage, with the API key and bearer token removed. - API version. Every request carries
version=<apiVersion>. The default,2026-09-25, is the date IBM’s Python SDK (ibm-watsonx-ai1.8.0) sends; setapiVersionin the section to pin another. - URLs and ids.
baseUrlandiamUrlmust behttps(plainhttponly forlocalhost,127.0.0.1and::1), because the IAM request carries the API key. The API key is trimmed. SetprojectIdorspaceId, not both. - Environment variables. Only
WATSONX_API_KEYis bound automatically.WATSONX_PROJECT_IDand the other variables in the example are just${?VAR}substitutions you write in your own config.
Perplexity
llm4s-perplexity calls Perplexity’s Agent API
(POST https://api.perplexity.ai/v1/agent), which replaced Sonar Chat Completions: Perplexity
ended Sonar’s support on 27 September
- The Agent API speaks the OpenAI Responses format, so it has its own client rather than the generic
openai-compatibleone. Adding the dependency registers theperplexityprovider.
1
libraryDependencies += "org.llm4s" %% "llm4s-perplexity" % llm4sVersion
1
2
3
4
5
6
llm4s.providers.perplexity-main {
provider = "perplexity"
model = "perplexity/sonar" # any Agent API model: perplexity/sonar, openai/gpt-5.6-sol, anthropic/claude-sonnet-5-5, ...
# apiKey defaults to PERPLEXITY_API_KEY (llm4s.credentials.perplexity.apiKey)
# webSearch = false # default true
}
| Key | Required | Meaning |
|---|---|---|
model |
yes | An Agent API model id, provider/model (the list) |
apiKey |
no | Default PERPLEXITY_API_KEY |
webSearch |
no | Offer the model Perplexity’s web_search tool on every request; default true. Each search is billed |
contextWindow |
no | The model’s window in tokens; default the model registry’s entry for the model’s vendor, else 128000 |
reserveCompletion |
no | Tokens held back for the reply; default 4096 |
- Citations.
Completion.citationsholds theurl_citationannotations on the answer, then each web search result (its URL, title and snippet ascitedText). The answer’s[web:N]markers point at those results. Bothcompleteand a stream carry them. - Usage and cost. Cached and reasoning tokens are read from the usage details, and the cost Perplexity
reports for the request (
usage.cost.total_cost, USD, searches included) isCompletion.estimatedCost. - Tools. Your tools are sent as
functiontools besideweb_search, and a reply’sfunction_callitems becomeToolCalls. A call’sthought_signature, which some models attach, is kept and sent back with it. - Request mapping. System messages become
instructions.ReasoningEffortis sent asreasoning.effort(Noneasminimal). AResponseFormat.JsonSchemais sent asresponse_format; plainResponseFormat.Jsonis refused, since the Agent API takes only a schema. Ananthropic/model with nomaxTokensis sentreserveCompletionasmax_output_tokens, which the Agent API requires for those models. Penalties have no counterpart and are not sent. Only text content is sent. - Not supported yet: effort levels (
effort), profiles, model fallback chains, the other built-in tools (fetch_url,finance_search, …), background runs andprevious_response_id. - No live call was made while writing this module. The
@CloudsuitePerplexitySmokeSpecruns it against the service withPERPLEXITY_API_KEY.
Azure OpenAI
Azure OpenAI ships in llm4s-openai with OpenAI itself, from the release after 0.4.1; see
OpenAI above. It runs on OpenAI’s Java SDK (com.openai:openai-java), which covers
Azure too: requests go to <endpoint>/openai/deployments/<deployment>/chat/completions with an
api-key header and the api-version query parameter. apiVersion accepts the wire form
(2024-10-21) or the constant name (V2024_10_21); an endpoint ending in /openai/v1 uses
Azure’s unified v1 API.
Setup
- Create resource in Azure Portal
- Deploy model (e.g., gpt-4o) to get deployment name
- Get credentials from Azure Portal → Keys & Endpoint
- Add a named section to
application.conf(below) and export the key, whichllm4s-openaibinds tollm4s.credentials.azure.apiKeyunder the openai SDK’s name, and the endpoint the section binds:
1
2
export AZURE_OPENAI_API_KEY=your-azure-key
export AZURE_API_BASE=https://your-resource.openai.azure.com
Configuration
In application.conf:
1
2
3
4
5
6
7
8
9
10
11
12
13
llm4s {
providers {
provider = "azure-main"
azure-main {
provider = "azure"
model = "gpt-4o" # your deployment name
endpoint = ${?AZURE_API_BASE} # required: your resource's endpoint
# apiKey = ${?AZURE_TEAM_API_KEY} # optional; default: AZURE_OPENAI_API_KEY
apiVersion = "2024-10-21" # optional; default V2025_01_01_PREVIEW
}
}
}
| Key | Required | Meaning |
|---|---|---|
model |
yes | The deployment name you chose when deploying the model |
endpoint |
yes | The Azure OpenAI resource endpoint |
apiKey |
no | The resource key; default AZURE_OPENAI_API_KEY |
apiVersion |
no | Azure API version, wire form or constant name |
authentication |
no | api-key (default) or entra-id: keyless Microsoft Entra ID |
clientId |
no | With entra-id, the client id of a user-assigned managed identity |
Available Models
Same as OpenAI (via Azure deployment). Choose models when deploying:
gpt-4ogpt-4-turbogpt-35-turbo
Reasoning deployments
reasoning_effort works as for OpenAI, with one difference: the model
llm4s sees is the deployment name, which need not name the model behind it. So:
- a deployment named after a model the registry knows (
o4-mini,gpt-5-mini,gpt-4o) is treated as that model, and one named after an Azure fine-tuned model (o4-mini-2025-04-16.ft-...) as its base model; - any other deployment (
prod-reasoner, or a fine-tune deployed under a name of your own) getsreasoning_effortwhenever you ask for a reasoning effort, since asking is the only sign it is a reasoning model. The request then also follows the reasoning-model rules:max_completion_tokens, and no sampling parameters. Asking for reasoning on such a deployment that is not a reasoning model gets an error from Azure; leavereasoningunset (orReasoningEffort.None) for it.
Streamed token usage
Streamed completions report token usage when apiVersion is 2024-09-01-preview or later
(including the default 2025-01-01-preview, the GA 2024-10-21 and the unified v1 API): only
those versions accept stream_options, so it is not sent for older ones, such as
2024-02-15-preview, and their streams report no usage.
Costs
Similar to OpenAI but often bundled with enterprise agreements.
Tips
- Use for VPC-isolated workloads
- Enterprise support available
- Same API as OpenAI (easy migration)
- Reserve capacity for predictable costs
DeepSeek
From the release after 0.4.1, DeepSeek ships in llm4s-openai-compatible, with Z.ai,
OpenRouter and the generic OpenAI-compatible provider; adding it
registers them:
1
libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion
In 0.4.1 and earlier it is part of llm4s-core. See the
installation guide.
Setup
- Get API key from platform.deepseek.com
- Export the key -
llm4s-openai-compatiblebinds it tollm4s.credentials.deepseek.apiKey:
1
export DEEPSEEK_API_KEY=sk-...
Configuration
In application.conf:
1
2
3
4
5
6
7
8
9
10
11
12
llm4s {
providers {
provider = "deepseek-main"
deepseek-main {
provider = "deepseek"
model = "deepseek-chat"
# apiKey defaults to DEEPSEEK_API_KEY
# baseUrl defaults to https://api.deepseek.com
}
}
}
Available Models
- Chat:
deepseek-chat(best for general use) - Reasoning:
deepseek-reasoner(extended thinking). Its chain of thought (reasoning_content) is returned on the message asAssistantMessage.thinking(and soCompletion.thinking), streamed as thinking deltas, and sent back asreasoning_contentin later requests, which DeepSeek’s thinking mode requires once tools are involved (see Thinking in conversation history).
Costs
Very competitive: ~$0.14-$0.28 per 1M input tokens
Tips
- Excellent cost/performance ratio
- Reasoning model rivals GPT-4o
- Good for translations and multilingual tasks
- Supports very long contexts
OpenRouter and Z.ai
Both ship in llm4s-openai-compatible from the release after 0.4.1, as DeepSeek does.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
llm4s {
providers {
openrouter-main {
provider = "openrouter"
model = "anthropic/claude-3.5-sonnet"
# apiKey defaults to OPENROUTER_API_KEY
# baseUrl defaults to https://openrouter.ai/api/v1
}
zai-main {
provider = "zai"
model = "GLM-4.7"
# apiKey defaults to ZAI_API_KEY
# baseUrl defaults to https://api.z.ai/api/paas/v4
}
}
}
Both sections are shown together for brevity. Only the section you load is validated, so only
its key - OPENROUTER_API_KEY or ZAI_API_KEY - needs to be set.
OpenRouter maps CompletionOptions.reasoning onto the underlying model: a thinking budget for
Claude models, reasoning_effort for OpenAI o-series models, nothing for the rest.
Z.ai maps it onto what the configured GLM model documents. Thinking is on by default, and
reasoning_effort defaults to max on the models that accept it. The mapping only ever rises with
the effort, and High sends max, Z.ai’s maximum and its default, so no level reasons more than
High:
| Model | ReasoningEffort.None |
Low |
Medium |
High |
|---|---|---|---|---|
| GLM-5.3, GLM-5.3-Flash, GLM-5.3-FlashX | reasoning_effort: low |
low |
high |
max |
| GLM-5.2 | reasoning_effort: none |
low |
medium |
max |
| GLM-5.1, GLM-5, GLM-4.7, GLM-4.6, GLM-4.5 (and variants) | thinking.type: disabled |
not sent | not sent | not sent |
| any other model | not sent | not sent | not sent | not sent |
GLM-5.3 always thinks and rejects thinking.type: disabled, so None gets the lowest effort it
accepts, as Z.ai advises; it still produces, and bills, thinking tokens, and the first such request
in a process logs a warning naming the model. GLM-5.3 accepts only low, high and max. GLM-5.2
accepts none, low, medium, high and max, but Z.ai currently runs low and medium as
high on it; llm4s still sends the level requested. The GLM-4.x and earlier GLM-5 models document
no reasoning_effort. With reasoning unset, neither field is sent. A GLM thinking
model’s reasoning_content on Z.ai, and a model’s reasoning and reasoning_details on OpenRouter,
are returned on the message and sent back (see Thinking in conversation history).
OpenAI-compatible endpoints
Any server that speaks the OpenAI /chat/completions API works with
provider = "openai-compatible" - hosted APIs such as Groq, Together, Fireworks, xAI or NVIDIA
NIM, local servers such as vLLM, LM Studio or llama.cpp, or an internal gateway. It needs no code
and no module of its own, only llm4s-openai-compatible, which ships in the release after 0.4.1
(see the installation guide):
1
libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion
Each named section is one endpoint, so several can sit side by side. The
recipes below are sections you can paste into your application.conf under
llm4s.providers.
Configuration keys
| Key | Required | Meaning |
|---|---|---|
baseUrl |
yes | Requests go to <baseUrl>/chat/completions, and model listing to <baseUrl>/models. A trailing / is dropped |
model |
yes | Sent as-is in every request |
apiKey |
no | Sent as Authorization: Bearer <key>; with none, no Authorization header is sent |
contextWindow |
no | The model’s context window; overrides the model registry. With none, the registry’s window for model when it has one of at least 8192 (see where the context window comes from), else 8192, which is deliberately small |
reserveCompletion |
no | Tokens held back for the reply. Default 2048, or a quarter of a smaller window |
registryProvider |
no | The model registry provider whose entry for model gives the context window when contextWindow is not set: groq, together_ai, fireworks_ai, xai, perplexity, … Inferred from the baseUrl host for those five hosted APIs; naming one here switches the inference off |
headers |
no | Extra headers sent on every request; values are redacted when the config is printed |
streamUsage |
no | Whether a streaming request asks for token usage with stream_options.include_usage. Default true; set false for an endpoint that rejects the field (see streamed token usage) |
baseUrl is everything before /chat/completions in the vendor’s endpoint URL. For most vendors
that is the host plus /v1, but some add a prefix - Groq’s is /openai/v1, Fireworks’
/inference/v1 - and Perplexity’s Sonar API has no /v1 at all.
Where the context window comes from
The generic provider cannot ask an arbitrary endpoint how large the model’s window is, so it takes it from the first of these that has an answer:
- the section’s
contextWindow; - the model registry’s entry for
modelunder theregistryProviderthe section names; - when the section names none, the registry’s entry under the provider inferred from the
baseUrlhost, for exactly these hosts:api.groq.com(groq),api.together.xyzandapi.together.ai(together_ai),api.fireworks.ai(fireworks_ai),api.x.ai(xai) andapi.perplexity.ai(perplexity); - 8192.
1
2
3
4
5
6
internal-gateway {
provider = "openai-compatible"
baseUrl = "https://llm-gateway.internal.example/v1" # not a known host, so name the registry provider
model = "llama-3.1-8b-instant"
registryProvider = "groq" # the window comes from groq/llama-3.1-8b-instant
}
- An explicit
registryProvideris an instruction, not a hint. It switches the host inference off, so aregistryProvider = "perplexity"on a Groq URL looks in Perplexity’s entries only. - The lookup is strict. Only the named provider’s own entry for exactly that model id counts. A partial name does not match, and neither does another provider’s entry for the same name, so a model the registry does not list under that provider gets the default, never a neighbour’s window.
- The host is matched exactly, never by substring.
https://api.groq.com.evil.example/v1,https://api.groq.com@evil.example/v1andhttps://evil.example/api.groq.comall get no registry window. NVIDIA NIM is not in the table because the registry’s threenvidia_nimentries carry no chat model with a window;registryProvider = "nvidia_nim"is still accepted. - The registry can only enlarge the window. A registry window below 8192 is ignored and the default used:
many entries, most of Fireworks’ and Perplexity’s older ones among them, carry a 4096 placeholder for input,
output and total alike (
fireworks_ai/.../minimax-m1-80kis one), which would cut an 80k-context model’s prompt budget in half and reject areserveCompletionthat fits 8192. A model whose window really is smaller setscontextWindow. The skipped entry is logged like a miss. - Only the window is taken.
reserveCompletionkeeps its own rule, a quarter of the window up to 2048, because a registry entry’s output limit can be as large as the whole window. - The registry does not know every model. It has no input limit for the Together recipe’s model, and does
not list xAI’s
grok-4.7or the NVIDIA NIM model at all, so those recipes keep theircontextWindow. A miss under an explicitregistryProvideris logged as a warning, and under an inferred one at info; a hit is logged at info with the entry it used.
Selecting it, and environment variables
Llm4sConfig resolves providers from named sections only, and reads no LLM_MODEL. The generic
provider has no vendor, so - unlike openai or anthropic - it has no shared
llm4s.credentials key and no variable is bound for it. Every value lives in the named section,
and you bind environment variables to it with HOCON substitutions, under names you choose. The recipes use each vendor’s own documented variable name, such as
GROQ_API_KEY:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
llm4s {
providers {
# the default named provider; LLM4S_PROVIDER overrides it when set
provider = "groq-main"
provider = ${?LLM4S_PROVIDER}
groq-main {
provider = "openai-compatible"
baseUrl = "https://api.groq.com/openai/v1"
model = "openai/gpt-oss-120b"
model = ${?GROQ_MODEL} # optional override
apiKey = ${?GROQ_API_KEY}
}
}
}
${?VAR} leaves the key unset when the variable is missing. For apiKey that is not an error
when the config loads, because the key is optional: the request goes out without an
Authorization header and fails with a 401 (see Troubleshooting).
For an endpoint chosen entirely from the environment, the conventional names are
OPENAI_COMPATIBLE_BASE_URL (required: the generic provider has no default endpoint) and
OPENAI_COMPATIBLE_API_KEY (optional), which OpenAICompatibleConfigKeys names:
1
2
3
4
5
6
7
8
9
llm4s.providers {
provider = "compatible-env"
compatible-env {
provider = "openai-compatible"
baseUrl = ${?OPENAI_COMPATIBLE_BASE_URL}
model = ${?OPENAI_COMPATIBLE_MODEL}
apiKey = ${?OPENAI_COMPATIBLE_API_KEY}
}
}
With OPENAI_COMPATIBLE_BASE_URL or the model variable unset, that section fails to load, so
keep it in the application.conf of deployments that set them.
The LLM_MODEL=openai-compatible/<model> shorthand is read, as LLM_MODEL=deepseek/<model> and
the other providers’ are, by the tools that read LLM_MODEL themselves: the chat-tui sample,
which then takes the endpoint from OPENAI_COMPATIBLE_BASE_URL and the key from
OPENAI_COMPATIBLE_API_KEY, and the config-policy env check, which checks
OPENAI_COMPATIBLE_BASE_URL rather than OPENAI_BASE_URL as the endpoint. Only the first /
separates the provider, so LLM_MODEL=openai-compatible/openai/gpt-oss-120b names Groq’s
openai/gpt-oss-120b.
Then load it by name, or as the default:
1
2
3
4
5
6
import org.llm4s.config.Llm4sConfig
import org.llm4s.llmconnect.config.ProviderConfig
import org.llm4s.types.Result
val groq: Result[ProviderConfig] = Llm4sConfig.provider("groq-main")
val dflt: Result[ProviderConfig] = Llm4sConfig.defaultProvider()
What the generic path does, and does not do
It sends the plain chat-completions format and reads the plain reply: text, streaming,
tool calling (streamed and not), response_format for JSON output, and token usage. Every
request carries temperature and top_p; max_tokens is sent when set, and
presence_penalty and frequency_penalty only when non-zero. It never sends stop or
tool_choice. Whether a feature works also depends on the endpoint and the
model: tool calling on a local server usually needs a server flag, as the recipes note.
Streamed token usage. A streaming request asks for usage with
"stream_options": {"include_usage": true}, which is how OpenAI - and servers that follow it,
such as vLLM, Ollama’s /v1 and Perplexity’s Orchestrator - are told to report it; usage is read from
whichever event carries it, including a final event with empty choices. An endpoint that
rejects unknown fields may refuse the request with a 400 or 422 naming stream_options. Turn it
off with streamUsage = false in the section (it takes true or false, and HOCON’s
yes/no/on/off; anything else is a config error naming the key and section):
1
2
3
4
5
6
strict-gateway {
provider = "openai-compatible"
baseUrl = "https://llm.example/v1"
model = "my-model"
streamUsage = false
}
or, building the config in code, with
OpenAICompatibleConfig.fromValues(..., streamUsage = false).
A non-streaming request fails with a timeout after two minutes without a response, and a streaming one after five minutes without one. Both are fixed for now; configurable timeouts are #712. A slow local model answering a long prompt can take longer than two minutes: stream it instead.
What it does not do:
- No reasoning configuration.
CompletionOptions.reasoningis ignored; noreasoning_effortor similar field is sent, so a reasoning model runs at its vendor’s default. - No reasoning output. A reply’s
reasoning_contentorreasoningfield is not read, soCompletion.thinkingis empty and reasoning tokens are not reported separately. - No provider-specific fields. Anything outside the standard reply - citations, search
results, annotations - is dropped;
Completionhas no field for it. - Text only. Messages are sent as text.
- No cost estimate unless the model name is one llm4s’s model registry knows.
A provider that needs any of these gets its own dialect in llm4s-openai-compatible: an
OpenAICompatibleDialect for how it departs from the standard format (reasoning parameters,
where its thinking is, extra response decoding) and a ProviderDescriptor to register it, as
DeepSeek, Z.ai, OpenRouter, Mistral and Cohere have.
Adding an OpenAI-compatible provider
in the contributing guide walks through the hooks, a worked example, registration, the tests to
write and the docs to update; see also
Adding a provider for the descriptor.
Hosted APIs
The values below were checked against each vendor’s API documentation in September 2026 (links under Provider docs). Model catalogues change often: if a model is retired, pick a current one from the vendor’s list and keep the rest of the section.
Groq
1
2
3
4
5
6
7
groq-main {
provider = "openai-compatible"
baseUrl = "https://api.groq.com/openai/v1"
model = "openai/gpt-oss-120b"
apiKey = ${?GROQ_API_KEY}
reserveCompletion = 8192
}
- The context window, 131072 for this model, comes from the model registry, because
api.groq.comis a known host. SetcontextWindowto override it, or for a model the registry does not list. - Model ids are Groq’s own, including the
openai/prefix here.llama-3.3-70b-versatile, which earlier versions of this guide used, was shut down for free and developer tiers on 16 August 2026. gpt-oss-120bis a reasoning model. Groq returns its reasoning in areasoningfield, which is not read, and the effort cannot be set.- Groq does not support
logprobs,logit_bias,messages[].nameorn> 1; the generic path sends none of them. Atemperatureof 0 is converted to 1e-8.
Together AI
1
2
3
4
5
6
7
together-main {
provider = "openai-compatible"
baseUrl = "https://api.together.ai/v1"
model = "meta-llama/Llama-3.3-70B-Instruct-Turbo"
apiKey = ${?TOGETHER_API_KEY}
contextWindow = 131072
}
- Keep
contextWindow: the registry lists this model with no input limit, so without it the provider would use 8192. (For a Together model the registry does give a limit for, it is taken automatically.) - Model ids are the
<organisation>/<model>API strings from Together’s serverless model list. - For a reasoning model on Together, the same reasoning limits apply as for Groq above.
Fireworks AI
1
2
3
4
5
6
fireworks-main {
provider = "openai-compatible"
baseUrl = "https://api.fireworks.ai/inference/v1"
model = "accounts/fireworks/models/gpt-oss-120b"
apiKey = ${?FIREWORKS_API_KEY}
}
- The context window, 131072 for this model, comes from the model registry. Set
contextWindowto override it, or for a model the registry does not list. - Model ids are full paths,
accounts/fireworks/models/<name>. Not every model in the catalogue is available serverless: check its model page before using it with a plain API key. - If the prompt plus
max_tokensexceeds the model’s window, Fireworks lowersmax_tokensrather than failing (its defaultcontext_length_exceeded_behavioristruncate). - Fireworks sends usage on the last chunk of a stream, so streamed token usage is reported.
reasoning_contentis not read.
xAI (Grok)
xAI has a provider of its own, provider = "xai", which reads Grok’s reasoning and
sends CompletionOptions.reasoning; prefer it. The generic path still works, for chat, streaming
and tools only:
1
2
3
4
5
6
7
xai-generic {
provider = "openai-compatible"
baseUrl = "https://api.x.ai/v1"
model = "grok-4.7"
apiKey = ${?XAI_API_KEY}
contextWindow = 500000
}
- Keep
contextWindow: the model registry does not listgrok-4.7, so without it the provider would use- (For an xAI model the registry does list, such as
grok-2, it is taken automatically.)
- (For an xAI model the registry does list, such as
- On this path Grok’s reasoning output is not read and the effort cannot be set, and xAI rejects
presencePenaltyandfrequencyPenaltyon reasoning models: leave them at 0.
NVIDIA NIM (hosted)
1
2
3
4
5
6
7
nim-cloud {
provider = "openai-compatible"
baseUrl = "https://integrate.api.nvidia.com/v1"
model = "meta/llama-3.3-70b-instruct"
apiKey = ${?NVIDIA_API_KEY}
contextWindow = 128000
}
- Keep
contextWindow: the model registry has no usable chat entry for NVIDIA NIM, andintegrate.api.nvidia.comis not one of the hosts the registry provider is inferred from. - Model ids are
<publisher>/<model>, as in NVIDIA’s API catalogue. For a NIM you run yourself, see NVIDIA NIM (self-hosted).
Perplexity
For Perplexity’s search-grounded answers use the perplexity provider: its Agent API speaks the
OpenAI Responses format, which this provider does not. Sonar Chat Completions, which this provider could reach,
lost Perplexity’s support on 27 September 2026.
Perplexity’s Orchestrator API (formerly the Router API) speaks Chat Completions and serves open-weight
models, without the Agent API’s web search. It is in private preview (access by request to Perplexity), and
publishes no context windows, so set contextWindow for the model you pick:
1
2
3
4
5
6
perplexity-orchestrator {
provider = "openai-compatible"
baseUrl = "https://api.perplexity.ai/orchestrator/v1" # the old /router/v1 still works
model = "perplexity/kimi-k3"
apiKey = ${?PERPLEXITY_API_KEY}
}
The Orchestrator reports token usage on a stream only when asked with stream_options, which the generic
provider sends, so streamed completions carry usage.
OrcaRouter
OrcaRouter is an OpenAI-compatible gateway in front of several vendors’ models:
1
2
3
4
5
6
orcarouter-main {
provider = "openai-compatible"
baseUrl = "https://api.orcarouter.ai/v1"
model = "openai/gpt-4o-mini"
apiKey = ${?ORCAROUTER_API_KEY} # keys start with sk-orca-
}
- OrcaRouter documents no environment variable name;
ORCAROUTER_API_KEYis only this guide’s choice. It publishes no per-model context window either: setcontextWindowto the upstream model’s. - Model ids are provider-prefixed (
openai/,anthropic/,google/, …). OrcaRouter accepts a reasoning effort as a model-name suffix (-minimal,-low,-medium,-high,-max), and sincemodelis sent as-is, that is the one way to choose an effort through the generic path. Thereasoning_contentit passes through is not read.
Local servers
A local server usually takes no API key: leave apiKey out and no Authorization header is
sent. Set it only if you started the server with one. Set contextWindow to the context the
server was started with, which may be smaller than the model’s maximum.
vLLM
1
vllm serve NousResearch/Meta-Llama-3-8B-Instruct --dtype auto
1
2
3
4
5
6
local-vllm {
provider = "openai-compatible"
baseUrl = "http://localhost:8000/v1"
model = "NousResearch/Meta-Llama-3-8B-Instruct" # --served-model-name, if you set one
# apiKey = ${?VLLM_API_KEY} # only if started with --api-key
}
- The served model name defaults to the
--modelargument. - Tool calling needs
--enable-auto-tool-choiceand a--tool-call-parserfor the model’s family; without them the model never calls a tool.
LM Studio
Start the server from LM Studio’s Developer page (default port 1234), then:
1
2
3
4
5
6
lmstudio {
provider = "openai-compatible"
baseUrl = "http://localhost:1234/v1"
model = "<the model identifier LM Studio shows>"
# apiKey = ${?LM_API_TOKEN} # only with "Require Authentication" on (LM Studio 0.4.0+)
}
- Use the identifier LM Studio shows for the loaded model, or list them with
Llm4sConfig.listModels("lmstudio").
llama.cpp (llama-server)
1
llama-server -hf <user>/<model>:<tag> --alias local-model -c 16384 --port 8080
1
2
3
4
5
6
7
llama-cpp {
provider = "openai-compatible"
baseUrl = "http://localhost:8080/v1"
model = "local-model" # the --alias
contextWindow = 16384 # the -c / --ctx-size the server was started with
# apiKey = ${?LLAMA_API_KEY} # only if started with --api-key
}
llama-serverlistens on127.0.0.1, on port 9931 by default in current builds; the command above pins 8080 with--port. Without--aliasthe model id is the model file’s path;--aliasgives it a stable name.- Tool calling uses the Jinja chat template (
--jinja, on by default), and some models need a--chat-template-fileoverride to get a tool-capable template.
NVIDIA NIM (self-hosted)
1
2
3
4
5
6
nim-local {
provider = "openai-compatible"
baseUrl = "http://localhost:8000/v1"
model = "meta/llama-3.1-8b-instruct" # the id GET /v1/models reports
# no apiKey: NVIDIA's examples for a running NIM send none
}
NGC_API_KEYis for pulling the container and model, not for requests: do not put it inapiKey.- The model id is the
idfromGET /v1/models; for a model-free NIM it isNIM_SERVED_MODEL_NAME.
Ollama (/v1)
llm4s-ollama (provider = "ollama") is the first-class route to
Ollama: it uses Ollama’s native /api/chat API, supports tool calling (tools are sent, tool
calls are read - whole or streamed - and tool results go back as role: tool) and also provides
Ollama embeddings. Tool calling needs a model whose Ollama page lists the tools capability: for any
other model Ollama answers HTTP 400 (... does not support tools), which the client reports as an
Invalid tools: Ollama model '<model>' does not support tool calling ... validation error rather than
sending the request again without the tools. Use the generic provider on Ollama’s OpenAI-compatible /v1 endpoint instead
when Ollama is behind a gateway that exposes only the OpenAI API, or when you prefer that wire format:
1
2
3
4
5
6
ollama-openai {
provider = "openai-compatible"
baseUrl = "http://localhost:11434/v1"
model = "gpt-oss:20b"
# no apiKey: local Ollama needs none
}
- The OpenAI API cannot set Ollama’s context size. To change it, create a model from a
ModelfilewithPARAMETER num_ctx <size>, use that model’s name, and setcontextWindowto the same size. - Ollama’s cloud API works the same way, with
baseUrl = "https://ollama.com/v1"andapiKey = ${?OLLAMA_API_KEY}. - A reasoning model’s thinking is not read on this route.
An internal gateway
A gateway that authenticates with its own header rather than a bearer token takes it in
headers:
1
2
3
4
5
6
7
8
9
internal-gateway {
provider = "openai-compatible"
baseUrl = "https://llm-gateway.internal.example/v1"
model = "gpt-4o-mini"
headers {
X-Team = "search"
X-Gateway-Token = ${?GATEWAY_TOKEN}
}
}
Config policy
If you gate configs with llm4s-config-policy, its dev preset allows openai-compatible and
its prod preset does not: since the provider can point anywhere, a production config naming it
fails with [allowedProviders] Provider 'openai-compatible' is not allowed until you allow it.
Allow it explicitly, and pin the models and the endpoint:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.llm4s.configpolicy.{ CatalogEnvironment, ConfigPolicy }
val policy = ConfigPolicy.prodSafeDefaults
.withAllowedProviders("openai", "anthropic", "azure", "gemini", "deepseek", "openai-compatible")
.withAllowedModelPatterns(
"^openai/gpt-4o(-mini)?$", // ...and the other preset patterns you still need
"^openai-compatible/openai/gpt-oss-120b$"
)
.withRequiredBaseUrlPattern(
CatalogEnvironment.Prod,
"openai-compatible",
"https://api\\.groq\\.com/openai/v1"
)
.withMaxContextWindow(CatalogEnvironment.Prod, "openai-compatible", 131072)
withAllowedProvidersandwithAllowedModelPatternsreplace the preset’s lists; repeat the entries you want to keep.- The
prodpreset also requires every section whose provider needs a key to set its ownapiKey(ruleownApiKey,withOwnApiKeyRequired). The generic provider’sapiKeyis optional, so it is not checked; the rule is about sections that would otherwise inherit a vendor’s shared key. - Model patterns match
<provider>/<model>, so a Groq model isopenai-compatible/openai/gpt-oss-120b. Model and base-URL patterns are regular expressions that must match the whole value, so^and$are optional:openai/gpt-4oallows neitheropenai/gpt-4o-mininor a dated snapshot such asopenai/gpt-4o-2024-08-06(writeopenai/gpt-4o(-.*)?for those). End a base-URL pin that allows paths with/.*, never a bare.*:https://api\.groq\.com.*also acceptshttps://api.groq.com.evil.example/v1. withRequiredBaseUrlPattern(env, provider, pattern)pins one provider’s endpoint and replaces the environment-wide pin for that provider. The environment-wide form,withRequiredBaseUrlPattern(env, pattern), is checked against every provider without a pin of its own, not onlyopenai-compatible; if you use it, name every endpoint in it (for examplehttps://(api\.openai\.com|api\.groq\.com/openai)/v1).- Context caps work the same way. The
prodpreset caps each of its providers at its current models’ window (openai and azure 128000, anthropic 200000, gemini 1048576, deepseek 131072) and any other provider,openai-compatibleincluded, at an environment-wide 1048576;devcaps everything at 1048576. So Groq, Together or Fireworks at 131072 and xAI at 500000 pass as they are. Set a per-provider cap withwithMaxContextWindow(env, provider, max), as above, to hold a provider tighter, or to allow a model above 1048576. A section over its cap fails withcontextWindow ... exceeds <max>. A window taken from the model registry counts too: a Groq section that sets nocontextWindowresolves to 131072 and is checked at that. - Provider names in a per-provider pin or cap are canonicalised like provider ids (
googleisgemini). One that names no registered provider and is not inallowedProvidersis an[unknownProvider]violation, so a typo cannot leave a provider unpinned.
See modules/config-policy/README.md for the rest of the module.
Troubleshooting OpenAI-compatible endpoints
404 on every request - wrong baseUrl. The client appends /chat/completions to baseUrl
and nothing else, so baseUrl must stop exactly where the vendor’s path to
/chat/completions begins. Missing /v1 (http://localhost:8000 for vLLM) and a doubled one
(.../v1/v1/chat/completions, from a baseUrl of .../v1/chat/completions or with /v1 added
twice) both return 404. Vendor paths differ: /openai/v1 for Groq, /inference/v1 for
Fireworks, no /v1 at all for Perplexity Sonar. Model listing (<baseUrl>/models) fails the
same way, which makes it a quick check.
401 or 403 - authentication. Surfaced as an AuthenticationError. Usually the key is not
reaching the request: apiKey = ${?GROQ_API_KEY} with the variable unset leaves apiKey out,
and the request goes without an Authorization header. Check the variable is exported in the
process that runs your app. Otherwise the key belongs to another vendor or project, or - for
NVIDIA - is the NGC_API_KEY used for pulling containers. For a local server started with
--api-key, set the same key as apiKey.
Model not found. Vendors answer an unknown model with a 404 or a 400. Model ids must match
exactly, prefix included (openai/gpt-oss-120b on Groq, accounts/fireworks/models/... on
Fireworks), and hosted catalogues retire models regularly. List what the endpoint serves, with
the section’s key and headers:
1
2
3
import org.llm4s.config.Llm4sConfig
Llm4sConfig.listModels("groq-main") // GET <baseUrl>/models
No API key for a local server. Leave apiKey out - do not set a placeholder. OpenAI’s SDKs
require some key, which is why vendor examples show api_key="ollama" or "not-used"; llm4s
does not, and without one it sends no Authorization header at all. A 401 from a local server
means it was started with a key (--api-key for vLLM and llama-server, “Require
Authentication” in LM Studio), and apiKey must then match it.
Missing baseUrl. A section without one fails to load with
baseUrl: set it in application.conf under llm4s.providers.<name>.baseUrl (e.g. http://localhost:8000/v1; to
read it from OPENAI_COMPATIBLE_BASE_URL, add baseUrl = ${?OPENAI_COMPATIBLE_BASE_URL} to the section).
Llm4sConfig reads no variable for it by itself: set baseUrl in the section, from a variable
if you like - baseUrl = ${?OPENAI_COMPATIBLE_BASE_URL} binds the conventional one. If the
section binds a variable and still fails this way, the variable is not set in the process that
runs your app.
Streaming fails with a 400 or 422 naming stream_options, while complete works. The
endpoint rejects fields it does not know. Set streamUsage = false in the section (see
streamed token usage); the stream then carries
usage only if the server sends it unasked.
Replies cut short, or context errors. Without a contextWindow, the generic provider uses the model
registry’s window when it knows the model (see
where the context window comes from), and otherwise 8192. Set it to the
model’s real window, or the server’s configured context for a local server.
Provider docs
The sources for the values above:
- Groq: OpenAI compatibility, models, deprecations, reasoning
- Together AI: OpenAI compatibility, serverless models
- Fireworks AI: OpenAI compatibility, quickstart, gpt-oss-120b
- xAI: quickstart, Chat Completions (legacy), models, reasoning
- NVIDIA NIM: hosted LLM APIs, Llama 3.3 70B Instruct, self-hosted quickstart, self-hosted API reference
- Perplexity: migrating from Sonar, Orchestrator API, Orchestrator models
- OrcaRouter: quickstart, reasoning
- vLLM: OpenAI-compatible server,
vllm serve, tool calling - LM Studio: OpenAI compatibility, authentication
- llama.cpp:
llama-serverREADME - Ollama: OpenAI compatibility
Mistral
From the release after 0.4.1, Mistral ships in llm4s-openai-compatible: Mistral’s API is
the OpenAI /v1/chat/completions format, so it runs on the shared client with a small dialect.
1
libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion
In 0.4.1 and earlier it is part of llm4s-core, and does not stream
(#925).
Setup
- Get API key from console.mistral.ai
- Export the key -
llm4s-openai-compatiblebinds it tollm4s.credentials.mistral.apiKey:
1
export MISTRAL_API_KEY=your-key
Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
llm4s {
providers {
provider = "mistral-main"
mistral-main {
provider = "mistral"
model = "mistral-large-latest"
# apiKey defaults to MISTRAL_API_KEY
# baseUrl defaults to https://api.mistral.ai - the API root; requests go to
# <baseUrl>/v1/chat/completions, and a baseUrl already ending in /v1 is used as it is
}
}
}
What it supports
Chat, streaming (text, tool calls and token usage), tool calling, structured output
(ResponseFormat.Json and ResponseFormat.JsonSchema), model listing and exchange logging.
- Tool-call ids. Mistral accepts only nine-letter-or-digit ids. Its own pass through; any other id in the conversation (from another provider, say) is mapped to a stable nine-character one, the same for the call and its answer.
- Reasoning models (Magistral) return their reasoning as
Completion.thinking, and stream it as thinking deltas. CompletionOptions.reasoningis not sent: only some Mistral models acceptreasoning_effort.
Cohere
From the release after 0.4.1, Cohere ships in llm4s-openai-compatible and calls Cohere’s
OpenAI-compatibility API
(https://api.cohere.ai/compatibility/v1) rather than its native v2 /v2/chat:
1
libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion
In 0.4.1 and earlier it is part of llm4s-core, calls /v2/chat, sends text only and does
not stream (#925).
Setup
- Get API key from dashboard.cohere.com
- Export the key - bound to
llm4s.credentials.cohere.apiKey, which the Cohere reranker inllm4s-ragshares, so one variable serves both. (Cohere’s Python SDK prefersCO_API_KEY; llm4s readsCOHERE_API_KEYonly, the name it has always used.)
1
export COHERE_API_KEY=your-key
Configuration
1
2
3
4
5
6
7
8
9
10
11
12
llm4s {
providers {
provider = "cohere-main"
cohere-main {
provider = "cohere"
model = "command-a-03-2025"
# apiKey defaults to COHERE_API_KEY
# baseUrl defaults to https://api.cohere.ai/compatibility/v1
}
}
}
A baseUrl naming Cohere’s native API root, as configs written for 0.4.1 do
(https://api.cohere.com, with or without /v1 or /v2), is mapped to
<root>/compatibility/v1 and logged; a proxy root is treated the same way. A baseUrl already
ending in /compatibility/v1 is used as it is.
What it supports
Chat, streaming (text, tool calls and token usage), tool calling, structured output and
exchange logging. System messages are sent under the developer role, and a JSON schema as
{"type": "json_object", "schema": ...}, as Cohere’s compatibility API documents.
CompletionOptions.reasoning is not sent. Cohere’s reranker (llm4s-rag) is separate and
unchanged.
Available Models
command-a-03-2025 and the other Command models your key can use; see
Cohere’s model list.
xAI (Grok)
xAI’s Grok models, through xAI’s OpenAI-compatible Chat Completions API
(https://api.x.ai/v1/chat/completions). The provider ships in llm4s-openai-compatible as a
dialect on the shared client (#1023), and is Beta:
1
libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion
Setup
- Get API key from console.x.ai
- Export the key -
llm4s-openai-compatiblebinds it tollm4s.credentials.xai.apiKey:
1
export XAI_API_KEY=xai-...
Configuration
1
2
3
4
5
6
7
8
9
10
11
12
llm4s {
providers {
provider = "xai-main"
xai-main {
provider = "xai"
model = "grok-4.7"
# apiKey defaults to XAI_API_KEY
# baseUrl defaults to https://api.x.ai/v1
}
}
}
The context window comes from the model registry when it lists the model, and otherwise from
xAI’s published windows: 500k for grok-4.5 to grok-4.7, 1M for grok-4.3 and grok-4.20,
256k for the other Grok 4 models and 131072 for older ones.
What it supports
Chat, streaming (text, tool calls and token usage), tool calling, images in user messages,
structured output, model listing (/v1/models) and exchange logging.
- Reasoning output. A reply’s
reasoning_content, where the model returns it (Grok 3 mini does), isCompletion.thinking, and streams as thinking deltas. Grok 4.5 and later keep their reasoning encrypted and return it only on xAI’s Responses API, so on Chat Completions they return none. Earlier turns’ thinking is not sent back: Chat Completions has no field for it. - Reasoning tokens are
TokenUsage.thinkingTokens, fromusage.completion_tokens_details.reasoning_tokens. xAI counts them outsidecompletion_tokensand insidetotal_tokens. - Reasoning effort.
CompletionOptions.reasoningis sent asreasoning_effortwhere the model takes it. xAI’s reasoning models cannot stop reasoning, soNonesends the lowest level, and the first such request in a process logs a warning:
| Model | ReasoningEffort.None |
Low |
Medium |
High |
|---|---|---|---|---|
grok-4.5, grok-4.6, grok-4.7 (and their -latest aliases) |
low |
low |
medium |
high |
grok-3-mini (and variants) |
low |
low |
high |
high |
| any other model | not sent | not sent | not sent | not sent |
high is the default on Grok 4.5 and later; xhigh (4.6 and later) is not reachable from
CompletionOptions. grok-4 rejects the parameter, and on grok-4.20-multi-agent it sets the
number of agents, so neither is sent it. With reasoning unset nothing is sent.
- Penalties. xAI rejects
presence_penalty,frequency_penaltyandstopon reasoning models. llm4s never sendsstop; a non-zeropresencePenaltyorfrequencyPenaltyis left out of a request to a reasoning model (logged once), and sent as given to a-non-reasoningmodel. - xAI calls Chat Completions its legacy endpoint; new features ship on its Responses API, which this provider does not speak.
Available Models
grok-4.7 and the other models your key can use; see xAI’s model list.
Ollama (Local Models)
From the release after 0.4.1, Ollama support - chat and embeddings - ships in its own
artifact, alongside llm4s-core:
1
libraryDependencies += "org.llm4s" %% "llm4s-ollama" % llm4sVersion
In 0.4.1 and earlier it is part of llm4s-core. See the
installation guide.
Setup
- Install Ollama from ollama.ai
- Pull a model:
1
2
ollama pull mistral # Downloads model
ollama serve # Runs on http://localhost:11434
- Add a named section to
application.confand select it as the default. No API key is needed, andbaseUrldefaults tohttp://localhost:11434, whereollama servelistens.
Configuration
In application.conf:
1
2
3
4
5
6
7
8
9
10
11
12
llm4s {
providers {
provider = "ollama-local"
ollama-local {
provider = "ollama"
model = "mistral"
# baseUrl = "http://gpu-box:11434" # optional: the default is http://localhost:11434
# baseUrl = ${?OLLAMA_BASE_URL} # optional: let OLLAMA_BASE_URL override the default
}
}
}
Structured output
llm4s-ollama honours CompletionOptions.responseFormat (and so completeStructured) through
the format field of /api/chat, for streaming and non-streaming calls alike:
ResponseFormat.Json sends "format": "json", and ResponseFormat.JsonSchema sends the schema
object, which needs Ollama 0.5 or later. JsonSchema.name and strict have no Ollama equivalent
and are ignored. With no responseFormat, no format field is sent.
Available Models
100+ models available:
- Small:
phi,neural-chat(~4GB) - Medium:
mistral,llama2(~13GB) - Large:
llama2-70b(~40GB) - Specialized:
neural-chat,orca,wizard-math
Run ollama list to see installed models.
Costs
Free! Just compute (CPU or GPU needed).
Tips
- Perfect for development and testing
- Works offline (no internet needed)
- Use GPU for faster inference
- Ideal for sensitive data (runs locally)
Thinking in conversation history
A model’s reasoning is returned on the message it produced, as AssistantMessage.thinking - a
sequence of ThinkingBlocks - and Completion.thinking is that message’s thinking text. Because it
lives on the message, it stays in the conversation (and in an agent’s thread) like the content and
tool calls do, and each client sends it back where its provider takes it:
| Provider | Read from | Sent back as |
|---|---|---|
| Anthropic | thinking blocks (each with its signature) and redacted_thinking |
the same blocks, unchanged and first in the assistant turn |
| Bedrock (Converse) | reasoningContent (text with signature, or redactedContent) |
the same blocks, unchanged and first in the assistant turn |
| Ollama | message.thinking |
thinking on the assistant message |
| DeepSeek, Z.ai | reasoning_content |
reasoning_content |
| OpenRouter | reasoning / thinking, and reasoning_details |
reasoning, and reasoning_details unchanged (same items, order and fields) |
| Mistral (Magistral) | thinking chunks in content |
a thinking chunk before the text chunk |
| Gemini API, Vertex AI | a thoughtSignature on a part: the functionCall part, or the last text part (and thought summaries, thought: true, as text) |
the signature, on the same part (below) |
OpenAI, Azure, Cohere, generic openai-compatible |
- | not sent: the API has no field for it |
| IBM watsonx.ai | reasoning_content (or reasoning) |
not sent: the chat API’s assistant message has no field for it |
Anthropic and Bedrock require the signed blocks back when a thinking model’s turn ends in tool
calls, and reject a thinking block without a signature, so unsigned thinking - from another
provider earlier in the conversation, or set by hand with withThinking(text) - is left out of
their requests. The Anthropic client sends tool calls and their results as tool_use and
tool_result blocks for the same reason: the thinking has to sit in the turn that made the calls.
Both clients send a call only when its result is in the run of tool messages straight after it, as
those APIs require; a result anywhere else (after a user message, say) goes as
[Tool result for <id>]: ... text, and its call is left out.
OpenRouter returns reasoning_details when the underlying model’s reasoning is signed, summarised
or encrypted (Claude, Gemini, OpenAI reasoning models), and
requires the whole sequence back unchanged
when a conversation continues after tool calls. Each item is kept, with every field, as a
ThinkingBlock.Opaque("openrouter", json) block after the reasoning text - streamed items are
joined by index first - and only the OpenRouter client sends them back; every other client ignores
opaque blocks that are not its own.
Gemini and Vertex AI thought signatures
A thinking Gemini model attaches an opaque, base64 thoughtSignature to a part of its turn and expects it
back on the same part. Google’s
Vertex AI guide states the rules
these clients follow:
- A response with
functionCallparts needs its signature back: Gemini 3 models answer HTTP 400 when a required signature is missing. With parallel calls only the firstfunctionCallpart carries one; across sequential steps each step’s first call does. The part goes back “exactly as it was returned”. - A response without function calls may carry a signature on its last part (streaming can deliver it on a part with empty text). Sending it back is recommended, and leaving it out is not an error.
- A part with a signature is never merged with one without.
Each signature is kept as a sealed ThinkingBlock.Opaque block of the client’s provider id (gemini or
vertexai) and goes back only to the provider and model that produced it. Unlike Anthropic’s prefix rule,
the binding is to that origin alone: Google asks for signatures to be preserved when history is modified or
trimmed, and Gemini 3 answers HTTP 400 when the current turn’s function-call signature is missing, so
pruning or compressing earlier turns leaves them in place. What unseals one is a change of provider or
model, or an edit to the message that carries it. A function call’s signature is stored against the tool
call’s id with the functionCall payload exactly as Gemini returned it, and the part is replayed verbatim
(its optional id kept, args present or absent as received); a populated functionCall.id becomes the
tool call’s own id and the matching functionResponse echoes it. A text part’s signature records its
character offset in the message content, and the content is split there when the turn is sent, so a signed
part is never merged with an unsigned one (if the content no longer fits, the text signature is left out).
Parts are rebuilt as text first, then function calls, as these clients have always sent them.
Google’s documentation does not say whether a signature from the Gemini API validates on Vertex AI, so the two clients are separate signing authorities: a conversation moved from one to the other keeps its text and drops the signatures, never sending a foreign one. A signature on an image part, or on a thought-summary part, is not kept, since these clients neither request nor send those parts.
Signed, redacted and opaque thinking is sealed: it is valid only in the conversation it was produced in.
Anthropic validates a
thinking block against everything sent before it - the top-level system prompt, the tools and every
earlier message - and rejects it (400) once any of those changes; a block must also come back
unchanged, beside the content and tool calls it came with. Bedrock documents its reasoning signature
as a hash of all the messages in the conversation. llm4s enforces both halves with two rules
(hasSealedThinking reports which state a message is in):
- The message itself.
withContentandwithToolCalls, given a changed value, unseal the thinking: they drop redacted and opaque blocks and signatures and keep the reasoning text. An agent’safterAgentanswer rewrite and a tool-call edit go through those setters, and so do the Anthropic and Bedrock clients when they leave out an unpaired call. - Everything before it. When Anthropic, Bedrock or OpenRouter returns sealed thinking, the client records a
fingerprint of the request on the message (
AssistantMessage.thinkingBinding): every system message, the tools, the response format and every earlier message in order, system messages in their positions (OpenRouter sends them inline, so moving one changes what came before), as sent. When the message is sent again, the client replays its sealed thinking only if the conversation before it still has that fingerprint, and sends it unsealed otherwise. The check runs at the point of sending, so it covers every rewrite of the history -ContextPruningand the agent’s context-window middleware,TokenWindowtrimming, the context compressors (DeterministicCompressor,LLMCompressor,HistoryCompressor,ToolOutputCompressor,ContextManager), a handoff’s view of the thread, memory or RAG context inserted before existing turns, a changed system prompt or tool set, or a hand edit - without any of them having to know about thinking. - Who produced it. The fingerprint also covers the provider id and model that produced the
thinking, and the client checks it against the provider and model it is about to call. A
signature or reasoning item belongs to its producer, so a conversation produced by one client
and continued with another - Bedrock then Anthropic, or one OpenRouter model then another - is
sent unsealed, never with a foreign signature. Every client binds to the provider and model it is
configured with, never the model a response reports: Anthropic and Bedrock may report an alias’s
resolved snapshot, and a router such as
openrouter/auto(or OpenRouter’s model fallbacks) reports the model it chose for that request. The provider that resolved the alias or chose the route is the one the thinking goes back to, and OpenRouter requires the completereasoning_detailssequence on a tool-call continuation, so routed turns keep it. Changing the configured model unseals every earlier turn. The endpoint is not part of it: a proxy or regional endpoint in front of the same provider changes nothing the provider checks.
Moving between Anthropic and Bedrock
A conversation that moves between the Anthropic API and Bedrock (a failover, say) has its signed thinking
unsealed: the text is kept, the signatures and redacted blocks are dropped, and nothing is rejected. Anthropic
documents that signature
values are compatible across platforms (the Claude API, Amazon Bedrock and Google Cloud), but that is only one
of the conditions for a replayed block to be accepted. The API also checks the model that produced it
(each model reads its own thinking blocks and those of a fixed set of other models) and that everything sent
before it is unchanged, and rejects the request with a 400 or drops the block when either fails. The two
clients name the same model differently (claude-sonnet-4-5-20250929 against
anthropic.claude-sonnet-4-5-20250929-v1:0) and serialise the system prompt and tools through different
APIs, so this library cannot show that the prefix a block was signed over is the prefix the other platform
sees. Losing the reasoning context on a failover is safe; a rejected request is not, so each remains its own
signing authority. Treating them as one would need a mapping between the two model ids and a live check that
a signature survives the move.
Limits that remain
- OpenRouter routers and fallbacks. OpenRouter’s documentation says dynamic routers (
openrouter/auto,openrouter/free) omit the reasoning field, so there is nothing to replay for them, and that thereasoning_detailssequence must match the original output with nothing rearranged or modified. It does not say what happens to the details when a fallback sends the request to a different model, so they are sent back unchanged, as its documentation asks. - Editing a tool call on a turn that is still in progress (a human-in-the-loop edit) unseals that turn’s thinking, because the signature covers the tool call. Anthropic’s manual extended thinking requires the final assistant turn of a thinking-enabled request to begin with a thinking block, so such a request can be rejected; adaptive thinking drops that requirement. Gemini 3 answers 400 for a missing function-call signature in the current turn.
Earlier turns whose history is unchanged keep their sealed thinking: Anthropic recommends passing
all thinking blocks back, keeps them in context on newer models, and accepts any unbroken run of the
original blocks. A change unseals every turn after it and none before it, so the replayed blocks never
have a gap. Unsealed thinking keeps its text, which Anthropic and Bedrock leave out and other
providers still receive. Sealed thinking with no binding - built by hand with withThinking - is
not replayed. To keep signed thinking, append to the conversation and leave what is already there as
it is. Thinking also counts toward token estimates (ConversationTokenCounter), since most providers
resend it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.llm4s.llmconnect.LLMClient
import org.llm4s.llmconnect.model.{ Completion, CompletionOptions, Conversation, ToolCall, ToolMessage }
import org.llm4s.types.Result
def toolTurn(
client: LLMClient,
conversation: Conversation,
options: CompletionOptions,
runTools: Seq[ToolCall] => Seq[ToolMessage] // your tool execution
): Result[Completion] =
for {
first <- client.complete(conversation, options) // a tool-call turn, with thinking
results = runTools(first.message.toolCalls)
next = conversation.addMessage(first.message).addMessages(results) // keeps the thinking
answer <- client.complete(next, options) // the provider gets it back
} yield answer
Citations and grounding sources
A model that searches the web by itself reports the sources behind its answer. llm4s returns them on
Completion.citations, a List[Citation] that is empty for every completion that has none:
1
2
3
4
import org.llm4s.llmconnect.model.Completion
def sources(completion: Completion): List[String] =
completion.citations.map(c => s"${c.title.getOrElse(c.url)} <${c.url}>")
A Citation has a url, always, and these fields, each None when the provider did not send it:
| Field | Meaning |
|---|---|
title |
the source’s title |
citedText |
a passage of the source that came with the citation (OpenRouter’s content); it describes the source, not the answer |
startIndex, endIndex |
where the inline citation sits, as the provider reports it: positions in Completion.content. OpenAI documents them as the first and last character “of the URL citation in the message”; no provider documents them as the span of prose the source supports |
Take the two indices as the location of the citation, not of the supported claim, and check the
provider’s convention before cutting content with them (whether end_index is inclusive is the
provider’s call). hasSpan says whether both are present.
| Provider | Citations |
|---|---|
| OpenAI (search models for Chat Completions), Azure, Requesty | Read from the message’s url_citation annotations (url, title, start_index, end_index). Search models (gpt-5-search-api; the *-search-preview models were retired on 2026-07-23) return them with no request option; Azure and Requesty return them only if the deployment does. |
OpenRouter (:online models, or the web plugin) |
The same annotations, plus content as citedText. |
DeepSeek, Z.ai, Mistral, Cohere, a generic openai-compatible endpoint |
Read if the reply carries standard url_citation annotations; none of them is documented to. |
| Gemini, Vertex AI | With googleSearch = true in the section (see Google Search grounding): one citation per source in the candidate’s groundingMetadata.groundingChunks - url and title, and for a Vertex AI Search / RAG source (retrievedContext) its text as citedText. No indices: Gemini’s groundingSupports are UTF-8 byte offsets of the supported text, not where a citation sits. Without the setting, empty. Read from complete and from a stream. |
| Anthropic | With webSearch = true in the section (see Web search): one citation per source a text block cites - for a web_search_result_location its url, title and cited_text, for a search_result_location its source as url. Document citations (char_location, page_location, content_block_location) name a document, not a URL, and are skipped. No indices: Anthropic attaches a citation to a text block, not to a position in it. Read from complete and from a stream (citations_delta). |
| Perplexity | The url_citation annotations on the answer, then each web_search result (url, title, the snippet as citedText) not already cited; see Perplexity. Read from complete and from a stream. |
| Ollama, Bedrock, watsonx | Empty. |
Things to know:
- Streaming. For OpenAI and OpenRouter, a completion assembled from streamed chunks has no citations:
neither documents where a stream carries them, so streamed chunks are not read. Gemini and Vertex AI send
groundingMetadatain a streamed chunk (usually the last), and the streamed completion carries it. Anthropic streams each citation as acitations_delta, and the streamed completion carries them. (A model that does not stream natively is answered with one ordinary call, and that completion keeps its citations.) - A citation never fails a reply. One without a
urlis dropped, a field of the wrong type is read as absent, and the answer is returned either way. The OpenAI client treats anannotationslist the SDK cannot parse at all as no citations; the OpenAI-compatible client keeps the entries it can read. - The shapes come from the OpenAI Java SDK’s
url_citationtypes and OpenRouter’s documentation. No live provider was called while writing this.
Enterprise authentication
Many organisations forbid long-lived API keys for cloud models and require the platform’s own
identity instead: a managed identity, a workload identity or an IAM role. Each of the three clouds
works that way in llm4s, with no key in application.conf.
| Cloud | Provider | How it authenticates without a key |
|---|---|---|
| Microsoft Azure | azure (llm4s-openai + llm4s-azure-identity) |
authentication = "entra-id": Microsoft Entra ID tokens from DefaultAzureCredential |
| AWS | bedrock (llm4s-bedrock) |
The AWS default credentials chain: environment, ~/.aws profile, an instance, task or IRSA role |
| Google Cloud | vertexai (llm4s-gemini) |
Application Default Credentials: GOOGLE_APPLICATION_CREDENTIALS, gcloud’s login, the metadata server |
Azure OpenAI with Microsoft Entra ID
Azure Policy can disable key (“local”) authentication on an Azure OpenAI resource. Such a
resource takes Microsoft Entra ID tokens instead, and so does llm4s
(#2274). Add llm4s-azure-identity beside
llm4s-openai:
1
2
// build.sbt - same version as llm4s-core
libraryDependencies += "org.llm4s" %% "llm4s-azure-identity" % llm4sVersion
and set authentication = "entra-id" in the section, with no apiKey:
1
2
3
4
5
6
7
8
9
10
llm4s.providers {
provider = "azure-prod"
azure-prod {
provider = "azure"
endpoint = "https://my-resource.openai.azure.com"
model = "gpt-4o" # the deployment name
authentication = "entra-id"
# clientId = ${?AZURE_CLIENT_ID} # a user-assigned managed identity
}
}
The token comes from Azure’s DefaultAzureCredential, which tries, in turn, environment
credentials (AZURE_TENANT_ID, AZURE_CLIENT_ID and a secret or certificate), workload identity
on AKS, a managed identity, and a developer’s az login. It is requested for the
https://cognitiveservices.azure.com/.default scope, cached, and fetched again five minutes before
it expires, so no request carries an expired token; one the service rejects with a 401 is
dropped, and the next request fetches a new one. The identity needs the Cognitive Services
OpenAI User role on the resource.
Such a section takes no API key: setting apiKey as well is an error, and the shared
AZURE_OPENAI_API_KEY is not used. Without llm4s-azure-identity on the classpath, loading the
section fails with a configuration error naming it. llm4s-azure-identity brings Azure’s
azure-identity (with azure-core, MSAL4J, Netty and Reactor), which is why it is a module of its
own: llm4s-openai alone puts no Azure SDK on your classpath.
In code, any AccessTokenProvider authenticates a config, and AzureEntraId makes one from Azure’s
DefaultAzureCredential or from any other TokenCredential:
1
2
3
4
5
6
7
8
import org.llm4s.llmconnect.LLMConnect
import org.llm4s.llmconnect.auth.azure.AzureEntraId
import org.llm4s.llmconnect.config.AzureConfig
import org.llm4s.model.ModelRegistryService
def azureClient(using ModelRegistryService) =
val tokens = AzureEntraId.defaultCredential() // or AzureEntraId.tokenProvider(anyTokenCredential)
LLMConnect.getClient(AzureConfig.keyless("https://my-resource.openai.azure.com", "gpt-4o", tokens))
Embeddings take the same tokens: OpenAIEmbeddingProvider.fromConfig(config, tokens), with the
resource’s v1 API as the base URL (https://my-resource.openai.azure.com/openai/v1) and the
embedding deployment’s name as the model. From Java, Llm4s.azureOpenAi().entraId() (or
.entraId(clientId), or .tokenProvider(tokens)) replaces .apiKey(...). With the Spring Boot
starter, the properties are the section’s keys: llm4s.providers.azure-prod.authentication=entra-id
and llm4s.providers.azure-prod.client-id=....
AWS Bedrock with IAM roles
A bedrock section that sets no accessKeyId uses the AWS SDK’s default credentials chain: the
AWS_* environment variables, a profile in ~/.aws, and the role of the EC2 instance, ECS task
or EKS service account (IRSA) the application runs as. See the
installation guide.
Vertex AI with Application Default Credentials
A vertexai section with no apiKey uses Application Default Credentials:
GOOGLE_APPLICATION_CREDENTIALS, then gcloud’s application_default_credentials.json, then the
metadata server of the GCE, GKE or Cloud Run workload. See Google Vertex AI.
API Key Management
Security Best Practices
Never commit API keys!
- Keep keys in environment variables. The vendor’s own variable is enough - each provider module binds it to the vendor’s shared key:
1
openai-main { provider = "openai", model = "gpt-4o" }
1
2
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=sk-ant-...
For a second account, or in production where each section should say which account it bills,
bind the section’s own key: apiKey = ${?OPENAI_BATCH_API_KEY}. The config-policy prod
preset flags sections without one. llm4s logs at INFO which path each key came from, never
the value.
- Use .env file (add to
.gitignore):
1
2
3
# .env (NOT committed to git)
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
- Use CI/CD secrets:
1
2
3
4
# GitHub Actions
- uses: actions/setup-java@v3
env:
OPENAI_API_KEY: $
- Rotate keys regularly on provider dashboards
Using Keys Safely in Code
Good:
1
2
3
4
import org.llm4s.config.Llm4sConfig
// Keys come from the vendor's variable or the section's own ${?VAR} binding - never hardcoded
val providerConfig = Llm4sConfig.defaultProvider()
Bad:
1
2
3
// ❌ Never do this!
val key = "sk-proj-abc123..." // Hardcoded
sys.env.get("OPENAI_API_KEY") // Outside config boundary
Base URL Customization
When to Use Custom Base URLs
- Reverse proxy or load balancer
- VPC endpoint for security
- Azure OpenAI or self-hosted setup
- Provider migration (e.g., from OpenAI to similar API)
Setting Custom URLs
A base URL is the baseUrl key of the named section (endpoint for Azure OpenAI). No
<PROVIDER>_BASE_URL variable is read by itself; bind one if you want the environment to
override the URL:
1
2
3
4
5
6
7
8
9
10
11
12
llm4s {
providers {
provider = "openai-main"
openai-main {
provider = "openai"
model = "gpt-4o"
baseUrl = "https://proxy.example.com/openai"
baseUrl = ${?OPENAI_BASE_URL} # optional: overrides the line above when set
}
}
}
In HOCON the later line wins, and ${?VAR} with the variable unset leaves the earlier value in
place. Defaults when baseUrl is omitted:
| Provider | Default baseUrl |
|---|---|
openai |
https://api.openai.com/v1 |
anthropic |
https://api.anthropic.com |
gemini |
https://generativelanguage.googleapis.com/v1beta |
deepseek |
https://api.deepseek.com |
mistral |
https://api.mistral.ai |
cohere |
https://api.cohere.ai/compatibility/v1 (a native root is mapped to <root>/compatibility/v1) |
openrouter |
https://openrouter.ai/api/v1 |
zai |
https://api.z.ai/api/paas/v4 |
requesty |
https://router.requesty.ai/v1 |
ollama |
http://localhost:11434 |
openai-compatible |
none - required |
Provider Comparison Table
| Feature | OpenAI | Anthropic | Gemini | Azure | DeepSeek | Cohere | Ollama | OpenAI-compatible |
|---|---|---|---|---|---|---|---|---|
| Setup Difficulty | Easy | Easy | Easy | Hard | Easy | Easy | Medium | Easy |
| API Key Required | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | Hosted ✅, local ❌ |
| Free Tier | Limited | Limited | ✅ Generous | ❌ | Limited | Limited | ✅ | Endpoint-specific |
| Local Option | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ vLLM, LM Studio, llama.cpp |
| Context Window | 128K | 200K | 1M | 128K | 4K-32K | 8K | Model-specific | Set in config (default 8K) |
| Vision Support | ✅ | ✅ | ✅ | ✅ | ⚠️ Limited | ❌ | Model-specific | ❌ Text only |
| Function Calling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ If the model supports it | ✅ If the endpoint supports it |
| Reasoning Models | ✅ o1 | ❌ | ❌ | ✅ (via OpenAI) | ✅ deepseek-reasoner | ❌ | ❌ | ⚠️ Run, but reasoning not configured or read |
| Enterprise Support | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | N/A | Endpoint-specific |
| Cost (Budget) | Medium | Medium | 🏆 Low | High | 🏆 Very Low | Low | Free | Endpoint-specific |
| Speed | Fast | Medium | 🏆 Very Fast | Medium | Fast | Medium | Varies | Endpoint-specific |
| Reliability | 🏆 Enterprise | 🏆 Enterprise | Good | 🏆 Enterprise | Good | Good | Local | Endpoint-specific |
Which Provider Should I Use?
- Getting started? → Try Gemini (free tier) or Ollama (local)
- Production API? → OpenAI (most stable) or Anthropic (best reasoning)
- Cost-conscious? → DeepSeek or Ollama
- Enterprise? → Azure OpenAI or Anthropic
- Private data? → Ollama (runs locally)
- xAI (Grok)? → xAI
- Groq, Together, Fireworks, NVIDIA NIM, or a local vLLM, LM Studio or llama.cpp server? → the generic OpenAI-compatible provider
- Reasoning tasks? → Anthropic Claude or DeepSeek reasoner
- Vision/multimodal? → OpenAI GPT-4o or Anthropic Claude
Multiple Providers in One App
Configure one named section per provider, then load the default or any section by name:
1
2
3
4
5
6
7
8
9
10
11
12
13
import org.llm4s.config.Llm4sConfig
import org.llm4s.llmconnect.LLMConnect
import org.llm4s.model.ModelRegistryService
// conversation: the Conversation to send
for {
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
primaryConfig <- Llm4sConfig.defaultProvider() // llm4s.providers.provider
fallbackConfig <- Llm4sConfig.provider("claude") // llm4s.providers.claude
primary <- LLMConnect.getClient(primaryConfig)
fallback <- LLMConnect.getClient(fallbackConfig)
} yield primary.complete(conversation).orElse(fallback.complete(conversation))
Each section is validated when it is loaded, so here both must have a key available;
a problem in either surfaces as the Left of this for. See
Switching providers.
This enables:
- Fallback logic - Use OpenAI, fall back to Anthropic
- A/B testing - Compare provider outputs
- Cost optimization - Use cheapest available provider
Troubleshooting
“Invalid API Key”
1
2
3
4
5
6
7
8
9
# Verify the vendor's variable is set
echo $OPENAI_API_KEY
# ...and that the section sets no apiKey of its own, which would win over it.
# The INFO log line "llm4s.providers.<name>: API key from <path>" says which was used.
# Check key format (starts with correct prefix)
# OpenAI: sk-proj-* or sk-*
# Anthropic: sk-ant-*
# Gemini: Should be long alphanumeric
“Connection refused”
For local providers (Ollama):
1
2
3
4
5
# Check if Ollama is running
curl http://localhost:11434/api/tags
# Start Ollama
ollama serve
“Model not found”
Check the model key of the section you selected, and that its provider is the one serving
that model (model = "gpt-4o" under provider = "openai"; no openai/ prefix):
1
2
3
# Check available models
# OpenAI: https://platform.openai.com/docs/models
# Anthropic: https://docs.anthropic.com/claude/reference/models
“Rate limit exceeded”
Use provider-specific strategies:
- OpenAI: Wait before retrying, use batching API
- Gemini: Upgrade from free tier
- Ollama: Increase system resources or use GPU