Skip to content

Provider profiles

The unit of LLM configuration is the endpoint: a wire format, a base URL, and a credential.

[providers.nougate-anthropic]
kind = "anthropic" # which wire format, not which company
base_url = "https://nougate.nouverse.tech/anthropic/v1"
api_key = "nougate" # a secret NAME, never the value
[providers.nougate-openai]
kind = "openai"
base_url = "https://nougate.nouverse.tech/openai/v1"
api_key = "nougate" # the same credential

An agent then says provider = "nougate-anthropic", and moving it to a different deployment is a one-word change.

The earlier shape — one environment variable per vendor, ANTHROPIC_API_KEY and OPENAI_API_KEY — could not express this at all. A credential belongs to a base URL, and one gateway key serves both of its protocol endpoints. Naming the secret once and referencing it twice says exactly that.

kind is only the wire format

Two values, and neither of them names a company:

anthropicthe Anthropic Messages API shape
openaithe OpenAI Chat Completions shape

The model behind antigravity:claude-sonnet-4-6 on an OpenAI-shaped endpoint is Claude, and nothing in the engine needs to care. A proxy can serve either format.

Common profiles

[providers.anthropic]
kind = "anthropic"
base_url = "https://api.anthropic.com/v1"
api_key = "anthropic_api_key"
[providers.openai]
kind = "openai"
base_url = "https://api.openai.com/v1"
api_key = "openai_api_key"
[providers.openrouter]
kind = "openai"
base_url = "https://openrouter.ai/api/v1"
api_key = "openrouter"
headers = { "HTTP-Referer" = "https://nouverse.tech" }
# Anything that speaks the OpenAI shape on your own network.
[providers.ollama]
kind = "openai"
base_url = "http://127.0.0.1:11434/v1"
api_key = "ollama"

headers is for literal, non-secret headers. A value that looks like a credential is refused at load rather than quietly committed.

Managing them

From the dashboard: Models (admin only) — endpoint, wire format, credential. Or:

Terminal window
nouride provider # profiles, and which have a credential
nouride provider add nougate-anthropic --kind anthropic \
--url https://nougate.nouverse.tech/anthropic/v1 --key-stdin
nouride provider rm nougate-anthropic

The key is read from stdin rather than taken as an argument. A key on a command line lands in shell history, in ps output, and in any CI log that echoes its commands.

Defaults, and per-agent overrides

[llm.defaults]
provider = "nougate-anthropic"
model = "claude-sonnet-4-20250514"
max_tokens = 8192
temperature = 0.7

One agent overrides any of it in its own pack:

.nouride/agents/nouva/config.toml
[llm]
provider = "nougate-anthropic"
model = "claude-sonnet-4-20250514"
max_tokens = 8192
temperature = 0.7

And one conversation overrides it again with /model — which is scoped to that chat, not to the agent.

Model versus Models is a real distinction. A profile is infrastructure, defined once and shared; a model is one agent’s choice within it. That is what lets one agent move without touching the others.

Budgets and limits

[llm.limits]
max_tokens_per_turn = 16384
max_tool_iterations = 25
max_turn_duration_ms = 300000
max_daily_cost_usd = 0 # 0 = off
max_daily_tokens = 0 # 0 = off
cost_alert_threshold_pct = 80

Two ceilings. Both are checked before the provider is called, and whichever is reached first refuses the turn.

max_daily_cost_usd can only see models that are in the price book. Measured on a live install over 24 hours that was 8 of 88 requests, and a daemon pointed at a self-hosted gateway has no priced models at all — so there the dollar cap is not a partial measure, it is nothing.

max_daily_tokens counts input and output together across every model, priced or not, because every response reports its own usage. If you set only one, set this one.

Usage and cost are recorded per turn and the dashboard shows today’s spend against the cap, using the same number the enforcement uses.

Context: compaction rather than truncation

[llm.context]
max_context_ratio = 0.8
truncation_strategy = "summary" # summary | sliding_window
preserve_recent_turns = 4
compact_at_ratio = 0.8
compact_at_tokens = 120000
preserve_recent_tool_results = 3
# summary_model = "claude-haiku-4-5-20251001"

sliding_window drops the oldest turns. Correct for fitting, wrong for remembering: an agent an hour into a job has no record of what it already did, so it repeats a step or reports success for one it cannot see.

summary — the default — replaces those turns with one compacted note and keeps it. It costs one provider call per compaction, not one per turn, and nothing happens at all until history crosses compact_at_ratio. A summary that fails or comes back empty leaves the history untouched and truncation proceeds exactly as sliding_window would, so the downside is bounded at the old behaviour.

compact_at_tokens is a flat ceiling, and the threshold is the lower of the two. It exists because a ratio scales with the window: on a million-token model the ratio put compaction near 610k, so a 426k-token chat never compacted once and every iteration of a turn resent all of it — 12M input tokens for one reply, and the turn died on its five-minute deadline while it was still making progress. A window says what a model can read; it was never a claim about what it should carry to answer well.

preserve_recent_tool_results is the same idea inside a single turn. Trimming replaces the content; the message itself always stays, because an orphaned tool call is rejected outright by Anthropic.

Prompt caching

The system prompt is split where its stability changes, and marked so a provider can cache the stable part:

TierHoldsChanges whenCached
filesidentity, user, agents, skills, memorythe pack is edited
capabilitythe harness blockthe toolset or the exec posture changes
requesttimestamp, model, senderevery request

The last tier never carries a marker — a breakpoint written after the timestamp is an entry the next request cannot read, which is a 25% surcharge rather than a saving.

Order is load-bearing independently of the markers, because an implicit prefix cache — all the OpenAI path has — stops matching at the first byte that differs. Putting the harness before the timestamp took the cache-eligible prefix from 5.0k tokens to ~8.0k on every request and on every one of up to 25 tool iterations.

Streaming

[llm.streaming]
enabled = true # accumulates the stream; partial replies are not sent
typing_indicator = true
progress_in_chat = true

progress_in_chat posts one message naming the tool the agent is running, edits it as the turn progresses, and replaces it at the end with ✓ 4 steps · 12s. One message and not one per step, because editing re-notifies nobody — a twenty-tool turn would otherwise be twenty phone notifications. A turn that runs no tools posts nothing at all.

Concurrency

[queue]
max_concurrent_per_agent = 1 # per chat. The loader refuses anything higher
max_concurrent_turns = 8 # across every conversation. 0 lifts the ceiling
max_queue_depth = 20
queue_timeout_ms = 300000

Per-chat concurrency is fixed at 1 and that is not a tuning decision: a conversation is stored as one JSON document, so two turns at once in the same chat would both read it and both write the whole thing back. The slower one wins and the other exchange is gone from the transcript with nothing logged. Different chats already run in parallel with no setting.

max_concurrent_turns is the total. Without it, fifty active chats meant fifty simultaneous provider calls — fifty times the spend per second, and a rate limit hit immediately.

queue_timeout_ms is matched to max_turn_duration_ms rather than picked on its own: a message queued behind a turn has to outlive that turn, or one slow reply silently costs the next message. The loader warns if it is the shorter of the two.