Skip to content

Commit 4ec2a66

Browse files
committed
feat(provider): native Kimi OAuth + 5 API-key providers
Adds six built-in providers to the data-driven registry. Native OAuth (like Claude/ChatGPT/Copilot): - Kimi Code — RFC 8628 device-flow OAuth against auth.kimi.com, access_token used directly as the API bearer (no proxy exchange), refreshed via refresh_token. New provider/kimi/{kimi_oauth,provider}, KimiWaiting login state, KimiDeviceCodeReady/LoginDone msgs, cmd::kimi_login_async, waiting panel, picker badge, and full multi-account wiring in account_switch. Selection::is_kimi() + LongLived::Kimi dispatch slot. API-key OpenAI-compat rows (registry array 10 -> 16), each with a dedicated Endpoint::from_spec arm: - deepseek (api.deepseek.com, root path, DEEPSEEK_API_KEY) - xai (api.x.ai/v1, XAI_API_KEY) - mistral (api.mistral.ai/v1, MISTRAL_API_KEY) - gemini (generativelanguage.googleapis.com/v1beta/openai, GEMINI_API_KEY|GOOGLE_API_KEY|OPENAI_API_KEY) - fireworks (api.fireworks.ai/inference/v1, FIREWORKS_API_KEY) catalog.hpp capability inference now recognizes the deepseek-v4/ reasoner/chat, grok, gemini, and magistral families as native tool-callers. Endpoints locked in openai_transport_test; Kimi wire parsing/expiry in kimi_token_test. All six verified routing end-to-end. 274/274 tests green.
1 parent cacd304 commit 4ec2a66

30 files changed

Lines changed: 1058 additions & 15 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to agentty. Versions follow [SemVer](https://semver.org/).
44

55
## [Unreleased]
66

7+
### Added
8+
- **Four more built-in providers: xAI (Grok), Mistral, Google Gemini, and Fireworks** (`--provider xai|mistral|gemini|fireworks`, or pick in `^P`). All are API-key OpenAI-compat rows — set the provider's key (`XAI_API_KEY`, `MISTRAL_API_KEY`, `GEMINI_API_KEY`/`GOOGLE_API_KEY`, `FIREWORKS_API_KEY`), `-k`, or the in-app prompt. Each gets a dedicated `Endpoint::from_spec` arm for its host/path (Gemini's OpenAI shim nests under `/v1beta/openai`; Fireworks under `/inference/v1`); capability inference now recognizes the `grok`, `gemini`, and `magistral` families as native tool-callers. (`registry.hpp`, `openai/transport.cpp`, `catalog.hpp`; `openai_transport_test`.)
9+
- **DeepSeek as a built-in provider (`--provider deepseek`, or pick it in `^P`).** Point agentty at DeepSeek's OpenAI-compatible API (`api.deepseek.com`) with a `DEEPSEEK_API_KEY` (or `-k`) — no new login flow, since DeepSeek issues static API keys, not developer OAuth. Its endpoints live at the root (no `/v1` prefix), so it gets a dedicated `Endpoint::from_spec` arm; the V4 model family (`deepseek-v4-pro`/`-flash`, `deepseek-chat`/`-reasoner`) is recognized as native tool-callers by the capability inference. (`include/agentty/provider/registry.hpp`, `src/provider/openai/transport.cpp`, `include/agentty/domain/catalog.hpp`; `openai_transport_test`.)
10+
711
## [0.3.1] - 2026-08-21
812

913
### Added

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Most terminal coding agents ship as a Node or Python app and send big chunks of
2323

2424
- **It's one native binary.** 16.7 MB, ~3 ms cold start, zero runtime dependencies — no Node, no Python, no `npm install`, no `node_modules`. Download and run.
2525
- **It sends only the relevant code.** Built-in retrieval (hybrid BM25 + dense embeddings, code-aware chunking, GraphRAG) fetches just the slices that matter — often cutting context by 80%+ vs. whole-repo dumping.
26-
- **It's not locked to one vendor.** Sign in with your Claude Pro/Max, or point it at OpenAI, Groq, OpenRouter, Cerebras, or a fully local Ollama model. Switch live with `^P`.
26+
- **It's not locked to one vendor.** Sign in with your Claude Pro/Max, or point it at OpenAI, Groq, OpenRouter, Cerebras, DeepSeek, xAI (Grok), Mistral, Gemini, Fireworks, or a fully local Ollama model. Switch live with `^P`.
2727
- **It's safe by default.** Shell and build commands run in a sandbox; air-gap an entire session over SSH with one command.
2828
- **It's open source (MIT)** and runs inside Zed over ACP.
2929

@@ -81,6 +81,9 @@ agentty --provider openai -m gpt-4o # GPT
8181
agentty --provider groq -m llama-3.3-70b # Groq
8282
agentty --provider ollama -m qwen2.5-coder # local model, no key
8383
agentty --provider openrouter # any model via OpenRouter
84+
agentty --provider deepseek -m deepseek-v4-pro # DeepSeek (DEEPSEEK_API_KEY)
85+
agentty --provider xai -m grok-4.6 # xAI Grok (XAI_API_KEY)
86+
agentty --provider gemini -m gemini-3.7-flash # Google Gemini (GEMINI_API_KEY)
8487
agentty -m claude-opus-4-5 # Claude (API key or Pro/Max OAuth)
8588
```
8689

cmake/AgenttySources.cmake

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ set(AGENTTY_PROVIDER_SOURCES
5959
src/provider/chatgpt/responses.cpp
6060
src/provider/copilot/provider.cpp
6161
src/provider/copilot/copilot_oauth.cpp
62+
src/provider/kimi/provider.cpp
63+
src/provider/kimi/kimi_oauth.cpp
6264
src/provider/openai/transport.cpp
6365
src/provider/ollama/transport.cpp
6466
src/provider/selection.cpp

cmake/AgenttyTests.cmake

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ set(_AGENTTY_CONSOLIDATED
1919
custom_host_key_prompt_test decomposition_memory_test dispatch_route_test
2020
model_label_test cache_anchor_test composer_edit_test hooks_gate_test
2121
midrun_freeze_test smart_mode_test stream_liveness_test wire_golden_test
22-
wire_shared_test complexity_test copilot_token_test routing_memory_test
22+
wire_shared_test complexity_test copilot_token_test kimi_token_test routing_memory_test
2323
smart_cascade_gate_test chatgpt_bundled_models_test settings_default_test
2424
update_check_test
2525
workspace_index_test

docs/website/providers.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ Even a 1M window eventually fills on a long session. When it does, you can [fork
4242
| `openrouter` | Any model via `openrouter.ai` | `OPENROUTER_API_KEY` |
4343
| `together` | Open models on `together.ai` | `TOGETHER_API_KEY` |
4444
| `cerebras` | Wafer-scale inference — very fast | `CEREBRAS_API_KEY` |
45+
| `deepseek` | DeepSeek V4 on `api.deepseek.com` | `DEEPSEEK_API_KEY` |
46+
| `xai` | xAI Grok models on `api.x.ai` | `XAI_API_KEY` |
47+
| `mistral` | Mistral / Codestral / Magistral on `api.mistral.ai` | `MISTRAL_API_KEY` |
48+
| `gemini` | Google Gemini via the OpenAI-compat API | `GEMINI_API_KEY` |
49+
| `fireworks` | Open models on `fireworks.ai` | `FIREWORKS_API_KEY` |
4550
| `ollama` | Local models at `localhost:11434` | None |
4651
| `host:port` | Any raw OpenAI-compatible endpoint | `OPENAI_API_KEY` |
4752
| `https://host[:port]/path` | Any OpenAI-compatible endpoint with a custom path prefix (e.g. a gateway serving on `/api` instead of `/v1`) | `OPENAI_API_KEY` |

include/agentty/domain/catalog.hpp

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ struct ModelCapabilities {
392392
// - Local families with native/trained tool-calling: qwen3, llama3.1,
393393
// llama3.3, mistral / mixtral / ministral, command-r, hermes,
394394
// firefunction, functionary, devstral, codestral, gpt-oss, granite,
395-
// glm-4, deepseek (v3/r1).
395+
// glm-4, deepseek (v3/v4/r1/reasoner/chat), grok, gemini, magistral.
396396
// - Any model >= ~14B parameters (large enough to follow tool schemas).
397397
//
398398
// Weak (treat with guards):
@@ -473,7 +473,10 @@ struct ModelCapabilities {
473473
contains(id, "devstral") || contains(id, "codestral") ||
474474
contains(id, "gpt-oss") || contains(id, "granite") ||
475475
contains(id, "glm-4") || contains(id, "deepseek-v3")||
476-
contains(id, "deepseek-r1");
476+
contains(id, "deepseek-v4") || contains(id, "deepseek-r1")||
477+
contains(id, "deepseek-reasoner") || contains(id, "deepseek-chat") ||
478+
contains(id, "grok") || contains(id, "gemini") ||
479+
contains(id, "magistral");
477480
// A strong family at >= ~7B is reliable; only flag it weak if it's
478481
// explicitly tiny (<= 3B), where even good families struggle.
479482
if (strong_family) return params_b != 0 && params_b <= 3;

include/agentty/provider/dispatch.hpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ using StreamFn = std::function<StreamResult(Request, EventSink)>;
4747
// Adding a long-lived provider = one enumerator here + one slot in Router +
4848
// one `slot_for` arm. Adding a per-call provider needs NONE of this — it flows
4949
// through the generic OpenAI-compat builder purely from its registry row.
50-
enum class LongLived : std::uint8_t { None, Anthropic, ChatGpt, Copilot };
50+
enum class LongLived : std::uint8_t { None, Anthropic, ChatGpt, Copilot, Kimi };
5151

5252
// Map a selection to its long-lived slot, purely from registry data: the
5353
// oauth_native flag (ChatGPT/Codex) and the Anthropic dialect. No label
@@ -62,7 +62,7 @@ enum class LongLived : std::uint8_t { None, Anthropic, ChatGpt, Copilot };
6262
// (OpenAI-compat / Ollama) are built inside dispatch from the active Endpoint,
6363
// so they need no slot here.
6464
struct ProviderRouter {
65-
StreamFn long_lived[4]{}; // indexed by LongLived: [None]=unused, [Anthropic], [ChatGpt], [Copilot]
65+
StreamFn long_lived[5]{}; // indexed by LongLived: [None]=unused, [Anthropic], [ChatGpt], [Copilot], [Kimi]
6666

6767
// Kind::ExternalAcp — drive an external ACP agent subprocess. Bound in
6868
// main() to stream_external_acp(agent_id, …); erased here so dispatch has
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#pragma once
2+
// agentty::provider::kimi — Kimi Code OAuth (device flow).
3+
//
4+
// Kimi Code (Moonshot AI) authenticates terminal clients with the OAuth 2.0
5+
// Device Authorization Grant (RFC 8628) against `https://auth.kimi.com`, then
6+
// serves an OpenAI-compatible chat API at `https://api.kimi.com/coding/v1`.
7+
//
8+
// Unlike GitHub Copilot, Kimi's device-flow access_token is used DIRECTLY as
9+
// the API bearer — there is no separate proxy-token exchange step. When the
10+
// access_token expires it is refreshed via the standard `refresh_token` grant.
11+
// So this module is the simpler sibling of copilot_oauth: request a device
12+
// code, poll for the token bundle, persist it, and refresh transparently.
13+
//
14+
// Three OAuth endpoints, all POST form-encoded to the OAuth host:
15+
// POST /api/oauth/device_authorization → device + user code
16+
// POST /api/oauth/token (grant=device_code) → token bundle (polling)
17+
// POST /api/oauth/token (grant=refresh_token) → refreshed token bundle
18+
//
19+
// The persisted bundle lives in `config_dir()/kimi_credentials.json`, sealed
20+
// with the same cred_crypt seam as every other provider credential.
21+
22+
#include <cstdint>
23+
#include <expected>
24+
#include <filesystem>
25+
#include <functional>
26+
#include <optional>
27+
#include <string>
28+
#include <string_view>
29+
30+
#include "agentty/auth/auth.hpp" // OAuthError
31+
32+
namespace agentty::provider::kimi {
33+
34+
// The persisted Kimi token bundle. `access_token` is the API bearer;
35+
// `refresh_token` mints a fresh one when it expires.
36+
struct KimiToken {
37+
std::string access_token; // send as `Authorization: Bearer <token>`
38+
std::string refresh_token; // used with grant_type=refresh_token
39+
std::int64_t expires_at_ms = 0; // skew-safe LOCAL expiry (now + expires_in)
40+
std::string token_type; // "Bearer"
41+
std::string scope; // granted scopes (informational)
42+
43+
[[nodiscard]] bool expired(std::int64_t skew_ms = 0) const noexcept {
44+
return expires_at_ms != 0 && now_ms() >= expires_at_ms - skew_ms;
45+
}
46+
[[nodiscard]] bool valid() const noexcept { return !access_token.empty(); }
47+
48+
static std::int64_t now_ms() noexcept;
49+
};
50+
51+
// The UI seam: agentty shows this to the user ("go to <url> and enter <code>").
52+
struct DeviceCode {
53+
std::string verification_uri; // https://auth.kimi.com/device (fallback)
54+
std::string verification_uri_complete; // pre-filled URL with the code embedded
55+
std::string user_code; // e.g. "WDJB-MJHT"
56+
int expires_in = 900;
57+
};
58+
using DeviceCodeSink = std::function<void(const DeviceCode&)>;
59+
using CancelProbe = std::function<bool()>;
60+
61+
// ── Device-flow login ─────────────────────────────────────────────────────
62+
// Requests a device code (delivered to `on_device_code` so the modal can show
63+
// it), then block-polls the token endpoint until the user approves, the code
64+
// expires, or `cancelled()` trips. On success the token bundle is persisted
65+
// and returned. `timeout_s` bounds the whole poll loop.
66+
[[nodiscard]] std::expected<KimiToken, auth::OAuthError>
67+
login(int timeout_s, DeviceCodeSink on_device_code, CancelProbe cancelled);
68+
69+
// ── Credential store ───────────────────────────────────────────────────────
70+
[[nodiscard]] std::filesystem::path credentials_path();
71+
[[nodiscard]] std::optional<KimiToken> load_token();
72+
bool save_token(const KimiToken& tok);
73+
bool clear_credentials();
74+
75+
// Cheap "is a Kimi credential present?" for the picker view (stat-cached).
76+
[[nodiscard]] bool signed_in();
77+
78+
// Force the next fresh_token() to refresh even if the cached token looks valid
79+
// (called after the API returns 401 mid-turn).
80+
void invalidate_cached_token();
81+
82+
// ── Per-turn token ─────────────────────────────────────────────────────────
83+
// Returns a valid access token for the API, refreshing via refresh_token when
84+
// the persisted one is expired (or a forced refresh was requested). nullopt
85+
// when not signed in or the refresh failed.
86+
[[nodiscard]] std::optional<KimiToken> fresh_token();
87+
88+
// ── Testing seam ───────────────────────────────────────────────────────────
89+
// Parse a token-endpoint JSON body into a KimiToken (expiry computed from
90+
// `expires_in` relative to `now_ms`). nullopt when the required fields are
91+
// missing. Exposed so the wire parsing is unit-testable without a network.
92+
[[nodiscard]] std::optional<KimiToken>
93+
parse_token_response(std::string_view json_body, std::int64_t now_ms);
94+
95+
} // namespace agentty::provider::kimi
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#pragma once
2+
// agentty::provider::kimi::KimiProvider — native Kimi Code provider.
3+
//
4+
// Kimi Code's inference API is OpenAI-Chat-compatible, served at a fixed base
5+
// URL (https://api.kimi.com/coding/v1), so this is a THIN wrapper over the
6+
// shared openai transport: on each turn it ensures a fresh OAuth access token
7+
// (auto-refreshed via refresh_token from the persisted device-flow bundle),
8+
// builds the Endpoint, stamps the token as the request auth, and delegates
9+
// streaming to openai::run_stream_sync. A 401 (token revoked early) triggers
10+
// one forced refresh + retry.
11+
//
12+
// It is a LongLived provider (owns the token cache) — constructed once in
13+
// main() like the Anthropic / ChatGPT / Copilot providers.
14+
15+
#include <string>
16+
#include <vector>
17+
18+
#include "agentty/domain/catalog.hpp" // ModelInfo
19+
#include "agentty/provider/provider.hpp"
20+
#include "agentty/provider/openai/transport.hpp" // openai::Endpoint
21+
#include "agentty/provider/stream_epilogue.hpp"
22+
23+
namespace agentty::provider::kimi {
24+
25+
class KimiProvider {
26+
public:
27+
KimiProvider() = default;
28+
29+
provider::StreamResult stream(provider::Request req, provider::EventSink sink);
30+
31+
// Builds the Kimi inference Endpoint. Shared by stream() and list_models().
32+
static provider::openai::Endpoint make_endpoint();
33+
};
34+
35+
// The account's live model catalog from Kimi's /models (falls back to a small
36+
// bundled list when offline / not signed in).
37+
[[nodiscard]] std::vector<ModelInfo> list_models();
38+
39+
// The account's default model slug (first catalog entry) or a safe fallback.
40+
[[nodiscard]] std::string default_model();
41+
42+
// Drop the cached model catalog so the next list_models() re-fetches.
43+
void invalidate_model_cache();
44+
45+
} // namespace agentty::provider::kimi

include/agentty/provider/registry.hpp

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ using ProviderPreset = ProviderDescriptor;
144144
// To add a provider: append a row here, and — if it's OpenAI-compatible with
145145
// a non-default wire path — add the matching `Endpoint` arm in
146146
// openai/transport.cpp::from_spec keyed on the same `id`.
147-
inline constexpr std::array<ProviderDescriptor, 10> kProviders{{
147+
inline constexpr std::array<ProviderDescriptor, 16> kProviders{{
148148
{"anthropic", "Anthropic", "Claude — OAuth (Pro/Max) or API key",
149149
Wire::AnthropicMessages, Lifetime::LongLived, AuthStyle::OAuthOrKey, false, {"", "", ""}, "api.anthropic.com"},
150150
{"openai", "OpenAI", "GPT / Codex — api.openai.com",
@@ -153,6 +153,8 @@ inline constexpr std::array<ProviderDescriptor, 10> kProviders{{
153153
Wire::OpenAIResponses, Lifetime::LongLived, AuthStyle::None, true, {"", "", ""}, "chatgpt.com", /*oauth_native=*/true},
154154
{"copilot", "GitHub Copilot", "Sign in with GitHub — Copilot models, no API key",
155155
Wire::OpenAIChat, Lifetime::LongLived, AuthStyle::None, false, {"", "", ""}, "api.githubcopilot.com", /*oauth_native=*/true},
156+
{"kimi", "Kimi", "Sign in with Kimi — Kimi K2 models, no API key",
157+
Wire::OpenAIChat, Lifetime::LongLived, AuthStyle::None, false, {"", "", ""}, "api.kimi.com", /*oauth_native=*/true},
156158
{"groq", "Groq", "Llama/Mixtral on Groq LPUs — very fast",
157159
Wire::OpenAIChat, Lifetime::PerCall, AuthStyle::ApiKey, false, {"GROQ_API_KEY", "OPENAI_API_KEY", ""}, ""},
158160
{"openrouter", "OpenRouter", "Any model via openrouter.ai",
@@ -161,6 +163,16 @@ inline constexpr std::array<ProviderDescriptor, 10> kProviders{{
161163
Wire::OpenAIChat, Lifetime::PerCall, AuthStyle::ApiKey, false, {"TOGETHER_API_KEY", "OPENAI_API_KEY", ""}, ""},
162164
{"cerebras", "Cerebras", "Wafer-scale inference — very fast",
163165
Wire::OpenAIChat, Lifetime::PerCall, AuthStyle::ApiKey, false, {"CEREBRAS_API_KEY", "OPENAI_API_KEY", ""}, ""},
166+
{"deepseek", "DeepSeek", "DeepSeek V4 — api.deepseek.com",
167+
Wire::OpenAIChat, Lifetime::PerCall, AuthStyle::ApiKey, false, {"DEEPSEEK_API_KEY", "OPENAI_API_KEY", ""}, ""},
168+
{"xai", "xAI (Grok)", "Grok models — api.x.ai",
169+
Wire::OpenAIChat, Lifetime::PerCall, AuthStyle::ApiKey, false, {"XAI_API_KEY", "OPENAI_API_KEY", ""}, ""},
170+
{"mistral", "Mistral", "Mistral / Codestral / Magistral — api.mistral.ai",
171+
Wire::OpenAIChat, Lifetime::PerCall, AuthStyle::ApiKey, false, {"MISTRAL_API_KEY", "OPENAI_API_KEY", ""}, ""},
172+
{"gemini", "Google Gemini", "Gemini models via the OpenAI-compat API",
173+
Wire::OpenAIChat, Lifetime::PerCall, AuthStyle::ApiKey, false, {"GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY"}, ""},
174+
{"fireworks", "Fireworks", "Open models on fireworks.ai",
175+
Wire::OpenAIChat, Lifetime::PerCall, AuthStyle::ApiKey, false, {"FIREWORKS_API_KEY", "OPENAI_API_KEY", ""}, ""},
164176
{"ollama", "Ollama", "Local models at localhost:11434",
165177
Wire::OpenAIChat, Lifetime::PerCall, AuthStyle::None, true, {"", "", ""}, ""},
166178
{"llama.cpp", "llama.cpp", "Local llama.cpp server at localhost:8080",

0 commit comments

Comments
 (0)