Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 35 additions & 17 deletions docs/oauth-subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ before. Tokens are stored 0600 (or the OS keyring with
Running `/provider` opens a **"How do you want to connect?"** chooser:

```text
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI, ChatGPT, Hugging Face)
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI, Kimi, ChatGPT, Hugging Face)
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
```

Expand All @@ -46,23 +46,26 @@ Pick **Sign in with OAuth** → the list of providers that do real OAuth → cho
```text
❯ OpenRouter browser sign-in · creates a key
xAI (Grok) browser or device code
Kimi Code device code (managed coding endpoint)
ChatGPT browser (Codex backend, ChatGPT Plus/Pro)
Hugging Face browser or device code
```

- **OpenRouter / xAI / ChatGPT / Hugging Face** are real OAuth: your browser
opens to approve → done (no key to paste). OpenRouter mints a key; xAI /
ChatGPT / Hugging Face store a refreshable bearer. Hugging Face requires a
one-time OAuth-app registration (no secret needed for "public" apps); the
preset pre-fills scopes, endpoints, and the OIDC issuer. The same chooser
appears in first-run onboarding. (xAI uses an opt-in preset — set
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see below.)
- **Device code (headless / SSH):** for a provider that supports it (xAI,
Hugging Face), press **d** on the list to get a code to enter on another
device instead of opening a browser. On an SSH session or headless Linux box
(no `DISPLAY`) device code is used automatically; set `ZERO_OAUTH_DEVICE=1`
to force it anywhere. The CLI equivalent is
`zero auth login <name> --device`.
- **OpenRouter / xAI / Kimi / ChatGPT / Hugging Face** are real OAuth: your browser
opens to approve → done (no key to paste). OpenRouter mints a key; xAI / Kimi /
ChatGPT / Hugging Face store a refreshable bearer. Hugging Face requires a
one-time OAuth-app registration (no secret needed for "public" apps); the
preset pre-fills scopes, endpoints, and the OIDC issuer. The same chooser
appears in first-run onboarding. (xAI and Kimi use an opt-in preset — set
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*` / `ZERO_OAUTH_KIMI_*`;
see below.)
- **Device code (headless / SSH):** for a provider that supports it (xAI, Kimi,
Hugging Face), press **d** on the list to get a code to enter on another
device instead of opening a browser. On an SSH session or headless Linux box
(no `DISPLAY`) device code is used automatically; set `ZERO_OAUTH_DEVICE=1`
to force it anywhere. The CLI equivalent is
`zero auth login <name> --device`. (Kimi is **device-code only** — it has no
loopback/browser flow, so `zero auth kimi` always uses the device path.)
- **ChatGPT / Claude are intentionally not in this list for the proxy path** —
use the dedicated `chatgpt-proxy` / `custom-anthropic-compatible` preset
(see §2) for subscription-via-proxy. ChatGPT *is* a first-class OAuth
Expand All @@ -83,9 +86,24 @@ Pick **Sign in with OAuth** → the list of providers that do real OAuth → cho
(browser, or `--device` for headless) works one-click; the token is used directly
on `api.x.ai/v1`. Without the opt-in, set `ZERO_OAUTH_XAI_CLIENT_ID` (and
endpoints, or an issuer) yourself via `ZERO_OAUTH_XAI_*`. Either way the preset is
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
Grok-CLI client that may change without notice.
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
Grok-CLI client that may change without notice.
- **Kimi Code — opt-in preset, device-code only** — `zero auth kimi` (or
`zero auth login kimi --device`) runs the RFC 8628 device-code flow against
`https://auth.kimi.com`. You approve on another device and enter the code; the
returned access token is stored and used **directly** as a bearer on Kimi's
managed coding endpoint `https://api.kimi.com/coding/v1` (an OpenAI-compatible
chat-completions endpoint) — no ID-token claim extraction is needed. Kimi has
**no browser/loopback flow**, so the device code is the only path (it is used
automatically, and `--device` is accepted but redundant). Like xAI, the preset
ships the public Kimi Code CLI client identity
(`17e5f671-d194-4dfb-9706-5516cb48c098`) and is opt-in via
`ZERO_OAUTH_ALLOW_PRESETS=1`; any field is overridable with `ZERO_OAUTH_KIMI_*`.
This is distinct from the `moonshot` catalog entry, which is the API-key path at
`https://api.moonshot.ai/v1` (set `MOONSHOT_API_KEY`). To override the managed
endpoint, set `baseURL` on the provider profile; to override the OAuth host,
set `ZERO_OAUTH_KIMI_ISSUER_URL`/`ZERO_OAUTH_KIMI_DEVICE_URL`/`ZERO_OAUTH_KIMI_TOKEN_URL`.
- **ChatGPT (Codex) — opt-in preset** — `zero auth chatgpt` opens a browser, you
approve with your ChatGPT Plus/Pro/Business/Enterprise account, and the bearer is
stored. The bearer routes to `https://chatgpt.com/backend-api/codex/responses`
Expand Down
19 changes: 14 additions & 5 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return runAuthOpenRouter(args[1:], stdout, stderr, deps)
case "chatgpt":
return runAuthChatGPT(args[1:], stdout, stderr, deps)
case "kimi":
// Kimi Code is a standard device-code OAuth preset (no bespoke client
// like ChatGPT's Codex flow), so it reuses the generic `auth login`
// engine — which already opts into presets and resolves the baked-in
// kimi client_id/endpoints. `zero auth kimi` is sugar for
// `zero auth login kimi`.
return runAuthLogin([]string{"kimi"}, stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown auth subcommand %q", args[0]))
}
Expand Down Expand Up @@ -589,14 +596,16 @@ Commands:
logout <provider> Delete a provider's stored login
status [provider] Show login presence/expiry (never the token)
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
openrouter Log in to OpenRouter in the browser; mints an API key
openrouter Log in to OpenRouter in the browser; mints an API key
chatgpt Log in to ChatGPT in the browser (Codex backend, ChatGPT Plus/Pro)
kimi Log in to Kimi Code via device code (managed coding endpoint)

A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
works out of the box. "xai" ('zero auth login xai') and "kimi" ('zero auth login
kimi' or 'zero auth kimi') use built-in presets that are off by default — enable
them with ZERO_OAUTH_ALLOW_PRESETS=1, or set the ZERO_OAUTH_XAI_* / ZERO_OAUTH_KIMI_*
vars yourself. "chatgpt" ('zero auth login chatgpt' or 'zero auth chatgpt') uses a
fixed-port loopback flow against the Codex backend.
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
Expand Down
15 changes: 15 additions & 0 deletions internal/oauth/presets.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ var builtinOAuthPresets = map[string]providerPreset{
Scopes: []string{"openid", "profile", "email", "offline_access", "api.connectors.read", "api.connectors.invoke"},
Flow: FlowLoopback,
},
// Kimi Code uses Moonshot's public OAuth client identity (the one the
// open-source `kimi-code` CLI ships). The flow is device-code only (RFC 8628)
// against auth.kimi.com — there is no loopback/authorize path — and the
// resulting access token is accepted directly as a bearer on the managed
// coding endpoint https://api.kimi.com/coding/v1 (an OpenAI-compatible
// endpoint). No ID-token claim extraction is needed; the bearer is the whole
// credential. Endpoints and client_id are lifted verbatim from the public
// kimi-code source (packages/oauth/src/constants.ts, oauth.ts).
"kimi": {
ClientID: "17e5f671-d194-4dfb-9706-5516cb48c098",
DeviceAuthorizationEndpoint: "https://auth.kimi.com/api/oauth/device_authorization",
TokenEndpoint: "https://auth.kimi.com/api/oauth/token",
Scopes: []string{"openid", "profile", "email", "offline_access"},
Flow: FlowDevice,
},
}

// lookupOAuthPreset returns the baked-in preset for a provider name (if any).
Expand Down
29 changes: 29 additions & 0 deletions internal/oauth/presets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,35 @@ func TestResolveConfigHuggingFaceWithEnvClientID(t *testing.T) {
}
}

// Kimi Code ships a baked-in client_id (the public kimi-code CLI identity) AND a
// device-code endpoint, so the preset resolves without env. The flow is device
// only (RFC 8628): no loopback/authorize endpoint, no issuer discovery.
func TestResolveConfigKimiPreset(t *testing.T) {
r := NewRegistry()
cfg, flow, err := r.ResolveConfig("kimi", map[string]string{"ZERO_OAUTH_ALLOW_PRESETS": "1"})
if err != nil {
t.Fatalf("ResolveConfig(kimi): %v", err)
}
if cfg.ClientID != "17e5f671-d194-4dfb-9706-5516cb48c098" {
t.Fatalf("client_id = %q", cfg.ClientID)
}
if cfg.DeviceAuthorizationEndpoint != "https://auth.kimi.com/api/oauth/device_authorization" {
t.Fatalf("device endpoint = %q", cfg.DeviceAuthorizationEndpoint)
}
if cfg.TokenEndpoint != "https://auth.kimi.com/api/oauth/token" {
t.Fatalf("token = %q", cfg.TokenEndpoint)
}
if cfg.AuthorizationEndpoint != "" {
t.Fatalf("authorize endpoint = %q, want empty (Kimi has no loopback flow)", cfg.AuthorizationEndpoint)
}
if flow != FlowDevice {
t.Fatalf("flow = %q, want device (RFC 8628 only)", flow)
}
if len(cfg.Scopes) == 0 {
t.Fatal("preset scopes should be populated")
}
}

// ChatGPT (Codex) ships a baked-in client_id (the public Codex CLI identity),
// so the preset resolves without env. The flow is loopback because the Codex
// backend requires a browser; there is no device-code path.
Expand Down
13 changes: 13 additions & 0 deletions internal/providercatalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ var descriptors = []Descriptor{
d.RequiresAuth = true
return oauthProvider(d, false, false)
}(),
// Kimi Code (managed OAuth) — the bearer from a Kimi Code device-code OAuth
// login routes to the managed coding endpoint at https://api.kimi.com/coding/v1
// (NOT api.moonshot.ai/v1, which is the API-key path for the `moonshot`
// catalog entry). Kimi only supports the RFC 8628 device-code flow against
// auth.kimi.com; the access token is accepted directly as a bearer on the
// managed endpoint, so no client spoofing is involved. The baked-in preset
// ships the public Kimi Code client_id and endpoints (off by default, like the
// other presets); env overrides via ZERO_OAUTH_KIMI_* win.
func() Descriptor {
d := openAICompat("kimi", "Kimi Code", "https://api.kimi.com/coding/v1", "kimi-k2.7-code-highspeed", nil)
d.RequiresAuth = true
return oauthProvider(d, false, true)
}(),
openAICompat("groq", "Groq", "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile", []string{"GROQ_API_KEY"}),
openAICompat("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat", []string{"DEEPSEEK_API_KEY"}),
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
Expand Down
8 changes: 6 additions & 2 deletions internal/providercatalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ var expectedCatalogIDs = []string{
"aimlapi",
"huggingface",
"chatgpt",
"kimi",
"groq",
"deepseek",
"together",
Expand Down Expand Up @@ -316,7 +317,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "aimlapi", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "aimlapi", "huggingface", "chatgpt", "kimi", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}

for transport, wantIDs := range cases {
Expand Down Expand Up @@ -375,7 +376,7 @@ func TestReturnedDescriptorsAreCopies(t *testing.T) {

func TestOAuthProviderClassification(t *testing.T) {
oauthIDs := descriptorIDs(OAuthProviders())
if want := []string{"openrouter", "huggingface", "chatgpt", "xai"}; !reflect.DeepEqual(oauthIDs, want) {
if want := []string{"openrouter", "huggingface", "chatgpt", "kimi", "xai"}; !reflect.DeepEqual(oauthIDs, want) {
t.Fatalf("OAuthProviders() = %#v, want %#v", oauthIDs, want)
}
if d, _ := Get("openrouter"); !d.OAuthMintsKey {
Expand All @@ -384,6 +385,9 @@ func TestOAuthProviderClassification(t *testing.T) {
if d, _ := Get("xai"); !d.OAuthDeviceFlow {
t.Fatal("xai should advertise device-code flow")
}
if d, _ := Get("kimi"); !d.OAuthDeviceFlow {
t.Fatal("kimi should advertise device-code flow")
}
if d, _ := Get("huggingface"); !d.OAuthDeviceFlow {
t.Fatal("huggingface should advertise device-code flow")
}
Expand Down
10 changes: 7 additions & 3 deletions internal/providercatalog/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import "testing"

func TestOAuthProviders(t *testing.T) {
providers := OAuthProviders()
if len(providers) != 4 {
t.Fatalf("OAuthProviders() = %d, want 4 (openrouter, xai, huggingface, chatgpt)", len(providers))
if len(providers) != 5 {
t.Fatalf("OAuthProviders() = %d, want 5 (openrouter, xai, kimi, huggingface, chatgpt)", len(providers))
}
byID := map[string]Descriptor{}
for _, d := range providers {
Expand All @@ -30,6 +30,10 @@ func TestOAuthProviders(t *testing.T) {
if !ok || cg.OAuthMintsKey || cg.OAuthDeviceFlow {
t.Fatalf("chatgpt oauth flags wrong: %+v", cg)
}
kimi, ok := byID["kimi"]
if !ok || kimi.OAuthMintsKey || !kimi.OAuthDeviceFlow {
t.Fatalf("kimi oauth flags wrong: %+v", kimi)
}
}

func TestOAuthProvidersReturnsIndependentClones(t *testing.T) {
Expand Down Expand Up @@ -58,7 +62,7 @@ func TestOAuthProvidersReturnsIndependentClones(t *testing.T) {
func TestNonOAuthProvidersNotFlagged(t *testing.T) {
for _, d := range All() {
switch d.ID {
case "openrouter", "xai", "huggingface", "chatgpt":
case "openrouter", "xai", "kimi", "huggingface", "chatgpt":
continue
}
if d.OAuth {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/provider_wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ func providerWizardMethodOptions() []providerWizardMethodOption {
options = append(options, providerWizardMethodOption{
oauth: true,
label: "Sign in with OAuth",
subtitle: "One-click browser login, no API key to copy (OpenRouter, xAI, ChatGPT, Hugging Face).",
subtitle: "One-click browser login, no API key to copy (OpenRouter, xAI, Kimi, ChatGPT, Hugging Face).",
})
}
options = append(options, providerWizardMethodOption{
Expand Down
Loading