Providers & configuration

Three providers, one program. Switching is an environment variable, not a code change.

export ECKO_AI_API_KEY=sk-...
export ECKO_AI_PROVIDER=openai        # or openrouter, ollama
export ECKO_AI_MODEL=gpt-4o-mini      # optional; each provider has a default

Or per run:

ecko --provider openrouter --model anthropic/claude-sonnet-4.5 report.ecko

The providers

providernotes
openaiThe default.
openrouterOne key, hundreds of models from many vendors. Model ids are vendor/model, e.g. anthropic/claude-sonnet-4.5 or google/gemini-2.5-pro.
ollamaLocal models. No key needed; runs against your own machine.

ECKO_AI_BASE_URL must be https, or http to a loopback host - your API key travels on it, and plain http would put it on the wire in cleartext and let the model's reply be altered in transit. Ollama's local default and a dev server on localhost are unaffected; ECKO_NET_ALLOW_HTTP=1 overrides, the same switch package fetching uses.

ECKO_AI_BASE_URL overrides the endpoint, which is how you point at a proxy, a gateway, or any other OpenAI-compatible server.

Reaching Anthropic, Google, Meta and the rest

Through openrouter. Ecko carried a direct Anthropic integration until 0.18 and it was removed: it was a second wire protocol to maintain - its own message shape, tool serialization, streaming frames, image encoding and error body - for one vendor, while OpenRouter reaches that vendor and many others through the protocol Ecko already speaks.

export ECKO_AI_PROVIDER=openrouter
export ECKO_AI_API_KEY=sk-or-v1-...
export ECKO_AI_MODEL=anthropic/claude-haiku-4.5

Setting ECKO_AI_PROVIDER=anthropic now fails with that instruction rather than being silently ignored.

What the runtime normalizes

The differences between providers are real - message shapes, tool-call serialization, streaming frame formats, image encodings, error bodies - and all of them are handled underneath. One ai expression with tools and a session works across all three, and a provider swap does not touch your source.

openai and openrouter share a wire format, so they share an adapter; ollama has its own. That is why adding OpenRouter cost a base URL and a name rather than a second protocol.

Errors are parsed per provider, so a failure reports what the provider actually said rather than an empty string.

One call, its own model: via

The environment sets the provider and model every call uses by default. A call can name its own with via, which is how one program puts different questions to different models - a cheap one to classify, a strong one to judge, a local one for data that must not leave the machine:

judge = model("openrouter", "anthropic/claude-sonnet-5")
local = model("ollama", "llama3.2", base_url: "http://gpu-box:11434")

verdict = ai[Verdict] "Judge this argument: {text}" via judge
notes = ai "Summarise the notes" using [read_notes] via local
quick = ai "One word: {q}" via "gpt-4o-mini"   # a model on the configured provider

via composes with every other clause, streaming and tools included. model(provider, name) takes openai, openrouter or ollama, with optional base_url:, key: and reasoning:; a plain string names a model on the provider the environment configures.

reasoning: sets how hard a reasoning model thinks on the calls it makes: none, minimal, low, medium, high, xhigh or max. Less is faster and cheaper, which suits a quick summary or a yes/no; more suits a judgement.

quick = model("openrouter", "openai/gpt-5-mini", reasoning: "low")
gist = ai "Summarise in one line: {text}" via quick

It goes to OpenRouter as reasoning.effort and to OpenAI as reasoning_effort. Ollama's think is only on or off, so none turns it off and any other effort leaves it on. A model that does not reason ignores it, and some reasoning models refuse none. ECKO_AI_REASONING sets it for every call that does not.

Keys follow the provider. A call that switches provider gets that provider's defaults and its key from key:, or else from OPENROUTER_API_KEY or OPENAI_API_KEY - never from ECKO_AI_API_KEY, which belongs to the environment's own provider. An OpenAI key is never sent to OpenRouter because one call asked for OpenRouter. A call with no key for its provider runs in mock mode, and ECKO_AI_MOCK=1 forces mock mode for every call.

Pass key: from the environment rather than writing it in source: model("openrouter", m, key: secret(os.env("OPENROUTER_API_KEY"))).

Configuration lives in the environment

There is no configure() call and no config file the runtime reads. The defaults come from the environment, and the only thing source can change is the provider and model of a call it makes - with via, written at the call site, where a review sees it. A library cannot change your provider behind your back.

For a project-level default, use the environment block in ecko.json - applied before evaluation to any variable the shell has not set, so a checkout runs the way its author intended unless the person running it says otherwise:

{ "environment": { "ECKO_AI_PROVIDER": "ollama", "ECKO_AI_MODEL": "llama3" } }

Never put a key in it. That file is committed - and ECKO_AI_API_KEY is one of the ten variables a manifest is refused, along with ECKO_AI_BASE_URL, since a manifest that could repoint the endpoint could send your key to it.

Local models

ollama is the zero-cost, zero-egress option, and worth reaching for when the data should not leave the machine. Quality differs from a frontier model, so it is a real choice rather than a drop-in - but the code is identical, so trying it costs one variable.

Typed calls are constrained, not just asked. Every provider reads the JSON Schema of an ai[T] call in its prompt; Ollama also receives it as format, which it enforces as a grammar on what the model may emit. A small local model that would answer a schema in the prompt with prose cannot do that here, so ai[Record] against a 7-9B model comes back as a record. Untyped calls, ai[Json] and tool rounds are left unconstrained - a model held to a schema could not choose to call a tool. Ollama before 0.5 rejects the field; set ECKO_AI_OLLAMA_FORMAT=0 for one of those.

No key at all

Everything still runs. See Mock mode.

The full list

Every setting is in Environment variables.