Providers

Every provider amber ships is a plugin: there is no core provider list to add to. With no plugins registered, /provider listis empty. A provider plugin owns its presets, its protocol, and its provider-specific features.

ProviderCapability

A provider contributes presets plus, optionally, thedialect (wire protocol) they speak:

struct ProviderCapability::Preset {
    std::string name;            // provider name, e.g. "gemini"
    std::string api_base;        // endpoint root
    std::string default_model;
    bool requires_key = true;
};
// Own protocol (Gemini): provide the dialect factory.
caps.push_back(std::make_unique<agent::ProviderCapability>(
    "gemini",
    [] { return make_gemini_dialect(); },
    std::vector<agent::ProviderCapability::Preset>{preset}));

// Shared protocol (DeepSeek speaks OpenAI wire): pass NO factory.
caps.push_back(std::make_unique<agent::ProviderCapability>(
    "openai",
    std::function<std::unique_ptr<agent::Dialect>()>{},   // empty
    std::vector<agent::ProviderCapability::Preset>{preset}));

The first argument is the flavor, the wire protocol identifier.register_dialect puts the factory into the same table the built-ins use; the presets become provider rows through the ordinary repository merge, so they appear in /provider list and the command drawer with no new concept.

Shared protocol vs own protocol

  • Shared (an OpenAI-compatible gateway): pass no dialect factory. You contribute presets and leave the shared openai protocol alone, so switching your plugin off cannot take the shared protocol down with it.
  • Own (Gemini, Anthropic): provide a factory. Your dialect is registered under your plugin id, so disabling the plugin removes exactly that flavor.

The Dialect port

Everything wire-specific lives here, endpoints, auth headers, request body, buffered parse, stream decoding, model listing, usage mapping, retry classification, overflow hints. A provider plugin must notopen its own HTTP client: lib/http_transport.cpp is the single curl mechanism for every provider.

class Dialect {
public:
    virtual std::string flavor() const = 0;

    virtual std::string chat_url(const Config& cfg) const = 0;
    virtual std::string models_url(const Config& cfg) const = 0;   // "" = none

    virtual std::vector<std::string> auth_headers(const Config& cfg) const = 0;

    virtual json build_chat_body(const Config& cfg,
                                 const std::vector<Message>& messages,
                                 const std::vector<std::shared_ptr<Tool>>& tools,
                                 bool stream) const = 0;

    virtual Message parse_completion(const std::string& raw) const = 0;
    virtual std::unique_ptr<StreamDecoder> make_decoder(
        Message& out, StreamDecoder::ChunkSink on_chunk, std::string debug_path) const = 0;

    virtual ServerInfo parse_models_response(const std::string& body,
                                             const std::string& preferred_model) const = 0;
    virtual std::vector<ModelInfo> parse_model_list_response(const std::string& body) const = 0;

    virtual TokenUsage parse_usage(const std::string& raw) const = 0;

    virtual bool is_retryable(long http_code, const std::string& body) const = 0;
    virtual int  context_overflow_hint(const std::string& error_body) const = 0;
};

void register_dialect(const std::string& flavor,
                      std::function<std::unique_ptr<Dialect>()> factory,
                      const std::string& owner = "");

Pure methods (build_chat_body, parse_*) are unit-testable without libcurl. Full contract:docs/spec/llm-client/dialect.md.

flowchart TB
    provider["provider plugin<br/>flavor + presets + optional dialect factory"] --> select["Config::flavor set at selection"]
    select --> make["make_dialect(flavor)<br/>resolved exactly once per client"]
    make -->|known flavor| dialect
    make -->|unknown flavor| fallback["openai fallback<br/>a typo never breaks a session"]
    fallback --> dialect["Dialect implementation<br/>endpoints · auth · body shape"]
    dialect --> request["build request body and headers"]
    request --> transport["HTTP transport via libcurl<br/>retry · timeout · cancel check"]
    transport --> endpoint["OpenAI-compatible endpoint"]
    endpoint --> decode["stream decoder: SSE framing"]
    decode --> chunks["StreamChunk per event → AgentHooks"]
    decode --> usage["TokenUsage mapping"]
    decode --> errors["is_retryable classification<br/>typed ApiError"]
    request --> normalize["tool calls normalized to the internal shape<br/>at the dialect edge, buffered and streamed alike"]
Provider plugin to endpoint, with the Dialect port resolving exactly once in the middle.

Wallet and allowance

If the vendor has an account balance or usage windows, declare aWalletCapability / AllowanceCapability. Supply only the fetch; the runtime owns polling, caching and rendering:

caps.push_back(std::make_unique<agent::WalletCapability>(
    [](const agent::Config& cfg) -> std::optional<double> {
        if (cfg.api_key.empty()) return std::nullopt;
        const auto body = agent::http_get_with_bearer(
            "https://api.deepseek.com/user/balance", cfg.api_key);
        if (!body) return std::nullopt;
        const double balance = parse_deepseek_balance(*body);
        return balance < 0 ? std::nullopt : std::optional<double>(balance);
    }));

Return std::nullopt when there is nothing honest to report, a provider with no account API declares nothing and the bar shows-, never a fabricated number.

Flavor resolution

A flavor nobody provides falls back to openai: correct for a typo in a user provider file. A flavor provided by a disabled plugin fails loudly, naming the plugin and the fix, rather than silently speaking the wrong protocol to a configured endpoint.

Adding one, step by step

1. Config-only first: if the endpoint speaks the OpenAI wire protocol,
   a *user* needs no code at all, a provider file is enough.
2. New directory plugins/<id>/ (+ its object in Makefile.in).
3. Shared protocol? -> pass no dialect factory.
   Own protocol?    -> write the dialect and register it.
4. Declare ProviderCapability (+ Wallet/Allowance if the vendor has them).
5. Register in make_bundled_plugins().
6. Test hermetically: body/parse/stream fixtures, no live network.

The shipped set

Nine providers ship as plugins, each small enough to read in one sitting:

PluginWhat it shows
plugins/custom/Presets only, no endpoint, the user's own endpoint
plugins/openrouter/Shared protocol + per-key wallet
plugins/kilocode/Shared protocol + provider-specific wallet
plugins/opencode_go/Shared protocol + allowance windows
plugins/opencode_zen/Shared protocol, presets only
plugins/commandcode/Shared protocol + 5h/weekly/monthly allowance
plugins/deepseek/Shared protocol + prepaid-balance wallet
plugins/anthropic/A vendor protocol the plugin itself provides
plugins/gemini/A vendor protocol with a different streaming model

The other bundled plugins are not providers. The tool set is one plugin per tunable unit (plugins/tool_search/, tool_read/,tool_write/, tool_bash/, tool_process/,tool_plan/, tool_task/,tool_skills/), the search backends are one plugin each (plugins/search_grep/, plugins/search_semantic/), and alongside them plugins/clock/ (the bar's clock),plugins/metrics/ (an event observer with no contributions),plugins/hello/ (the command example) andplugins/amber_self/ (the amber-config skill),23 bundled plugins in total. Seethe developer guide.

amber 0.5.8Apache 2.0C++17
utf-8ncursesJacek Trefon www.trefon.com2026