EmpryoEmpryo.beta
providers

Custom providers

Connect any OpenAI-compatible API to Empryo with one config block: set baseURL, key env var, models, dynamic model lists, and reasoning options.

Add any OpenAI-compatible API as a provider. No code, no plugin.

Minimal example

Add this to ~/.empryo/config.json (or .empryo/config.json for project scope):

{
  "providers": [
    {
      "id": "together",
      "name": "Together AI",
      "baseURL": "https://api.together.xyz/v1",
      "envVar": "TOGETHER_API_KEY",
      "models": ["meta-llama/Llama-3-70b-chat-hf"]
    }
  ]
}

Set the key and use it:

empryo --set-key together <your-key>
empryo --model together/meta-llama/Llama-3-70b-chat-hf

Fields

FieldRequiredWhat it does
idyesUsed in model strings (together/llama-3)
baseURLyesOpenAI-compatible endpoint
name-Display name
envVar-Env var for the key (omit for auth-less servers)
models-Model list (strings or {id, name, contextWindow, maxOutputTokens}) — declared limits drive the context gauge and auto-compaction
modelsAPI-URL returning OpenAI /v1/models format
disabled-Park the provider: models leave the picker, nothing fetches its endpoints
reasoning-Thinking/reasoning config, including auto (see below)
extraBody-JSON merged into every chat request to this provider (see below)

Declare model limits

Empryo cannot know what a self-hosted gateway actually serves, so an undeclared model is assumed to have a 128k context window. If your model is smaller, auto-compaction aims at the wrong ceiling: the trigger waits for ~70% of 128k while the gateway rejects the request far earlier — it looks like compaction "doesn't work". Declare the real limits per model:

{
  "providers": [{
    "id": "local",
    "baseURL": "http://localhost:8080/v1",
    "models": [
      { "id": "qwen-32k", "name": "Qwen 32k", "contextWindow": 32768, "maxOutputTokens": 4096 }
    ]
  }]
}
  • contextWindow sizes the context gauge and fires auto-compaction at the

right point.

  • maxOutputTokens sizes the output reserve (how much room a reply keeps).
  • If the provider's /v1/models endpoint reports a window, the live value

wins; your declaration is the offline/undeclared fallback.

  • Per-model override without touching the provider block:

"contextWindowOverrides": { "local/qwen-32k": 32768 } at the config root.

More examples

Thinking / reasoning

For models that support reasoning over an OpenAI-compatible endpoint, add a reasoning block. Three styles are supported - use the one your provider accepts:

{
  "providers": [{
    "id": "dashscope",
    "baseURL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
    "envVar": "DASHSCOPE_API_KEY",
    "models": ["qwen-plus"],
    "reasoning": {
      "enabled": true,
      "budget": 8192
    }
  }]
}
StyleFieldsWhen to use
OpenAI-style`effort: "low" \"medium" \"high" \"xhigh" \"none"`OpenAI-compatible reasoning models
DashScope-styleenabled: true, budget: 8192Alibaba Qwen, DashScope APIs
RawextraParams: { ... }Anything else - fields are forwarded verbatim
Automaticauto: trueYou do not want to write any of the above - see below

The fields are injected into every request body. Raw extraParams override all other keys on collision.

auto - let Empryo pick the body

A reasoning block is a declaration: without one, Empryo asks your endpoint for nothing, and a reasoning model it resells thinks at whatever the endpoint defaults to - often invisibly, because nothing requested the thinking in the first place. If you would rather not work out which knob your backend speaks, turn on auto:

{
  "providers": [{
    "id": "my-gateway",
    "baseURL": "https://llm.internal.corp.com/v1",
    "envVar": "MY_GATEWAY_KEY",
    "reasoning": { "auto": true }
  }]
}

Empryo then looks each model up in the models.dev catalog and sends the standard OpenAI-compatible knob only where the catalog says that model reasons - clamped to the effort ladder the catalog declares, plus the vendor's thinking toggle where one exists (enable_thinking for Qwen, thinking: { type } for GLM and Kimi). A model the catalog does not know, or knows as non-reasoning, is left completely alone.

auto is off by default and changes nothing for a provider that does not set it: without it, requests are byte-for-byte what they always were. It also yields to any explicit declaration - set effort, enabled, budget or extraParams and those are used instead, because you have already answered the question auto exists to answer. /effort and --effort keep working either way; an effort you pick beats the automatic default.

Reach for it when your provider resells a reasoning model and you never see any thinking (Empryo #182).

How effort is sent

By default effort goes out as the flat OpenAI field, { "reasoning_effort": "high" } — the field every OpenAI-compatible endpoint speaks. If your backend instead wants the nested OpenRouter shape, set effortStyle:

{
  "reasoning": {
    "effort": "high",
    "effortStyle": "nested"
  }
}
effortStyleSends
"flat" *(default)*{ "reasoning_effort": "high" }
"nested"{ "reasoning": { "effort": "high" } }
"both"both keys at once

Only reach for "both" if a router genuinely needs it. Strictly-validating backends reject a request carrying both keys outright — *"Pass either reasoning (nested object) or reasoning_effort (flat field), not both."*

Extra request-body parameters

Some backends turn a capability on with a field in the request body rather than with a tool. DashScope reads enable_search, Zhipu reads web_search, and a gateway you run yourself reads whatever you taught it. extraBody sends the JSON you write with every chat request to that provider.

{
  "providers": [{
    "id": "dashscope",
    "baseURL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
    "envVar": "DASHSCOPE_API_KEY",
    "models": ["qwen-plus"],
    "extraBody": { "enable_search": true }
  }]
}

The desktop app has the same field in its provider dialog, below Models. It checks the JSON as you type and keeps the save button off until the JSON parses.

Empryo merges these keys last, after everything it built for the request, so they win against anything they collide with. A key your API rejects fails the whole request rather than being ignored, which is what a raw passthrough has to mean.

reasoning.extraParams is the older field and it is not the same thing. Those params describe thinking, so they stop being sent when effort is off. extraBody goes out at every effort level. Put thinking schemas in one and everything else in the other.

To send a param on a built-in lane instead of a custom provider, use the `extraBody` map in your config, keyed by provider or model id.

Disable without removing

Park a provider you are not using right now: click the power button next to it in the model picker's provider manager, or set "disabled": true on its config entry. A parked provider keeps its config, key binding and model list, but Empryo stops registering it - its models leave every picker and nothing calls its endpoints. The row stays in the manager, grayed out, one click from coming back.

Useful when a local server (Ollama, LM Studio) is offline and you are tired of its models cluttering the picker, or when you switch between endpoints and want the spare one dormant instead of deleted.

Scope

  • Global - ~/.empryo/config.json applies everywhere.
  • Project - .empryo/config.json overrides global entries with the same id.

Conflicts

If your id matches a built-in (e.g. "anthropic"), Empryo renames it to {id}-custom - the built-in is never replaced. Custom providers always show [custom] in /keys and --list-providers.