diff --git a/docs/oauth-subscriptions.md b/docs/oauth-subscriptions.md index 6bb70c98d..efc17b997 100644 --- a/docs/oauth-subscriptions.md +++ b/docs/oauth-subscriptions.md @@ -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 ``` @@ -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 --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 --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 @@ -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` diff --git a/internal/cli/auth.go b/internal/cli/auth.go index f3ecdcc42..7aee15ba2 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -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])) } @@ -589,14 +596,16 @@ Commands: logout Delete a provider's stored login status [provider] Show login presence/expiry (never the token) refresh [--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 , set: ZERO_OAUTH__CLIENT_ID (required) ZERO_OAUTH__CLIENT_SECRET (optional) diff --git a/internal/oauth/presets.go b/internal/oauth/presets.go index 3f8b7fe79..e9cf3459d 100644 --- a/internal/oauth/presets.go +++ b/internal/oauth/presets.go @@ -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). diff --git a/internal/oauth/presets_test.go b/internal/oauth/presets_test.go index f73f1e102..92b2af324 100644 --- a/internal/oauth/presets_test.go +++ b/internal/oauth/presets_test.go @@ -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. diff --git a/internal/providercatalog/catalog.go b/internal/providercatalog/catalog.go index 1edb7f881..b59138ce0 100644 --- a/internal/providercatalog/catalog.go +++ b/internal/providercatalog/catalog.go @@ -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"}), diff --git a/internal/providercatalog/catalog_test.go b/internal/providercatalog/catalog_test.go index ade533b3f..c3d689062 100644 --- a/internal/providercatalog/catalog_test.go +++ b/internal/providercatalog/catalog_test.go @@ -19,6 +19,7 @@ var expectedCatalogIDs = []string{ "aimlapi", "huggingface", "chatgpt", + "kimi", "groq", "deepseek", "together", @@ -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 { @@ -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 { @@ -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") } diff --git a/internal/providercatalog/oauth_test.go b/internal/providercatalog/oauth_test.go index 0733f32ae..2cbe359bd 100644 --- a/internal/providercatalog/oauth_test.go +++ b/internal/providercatalog/oauth_test.go @@ -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 { @@ -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) { @@ -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 { diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index 90c814ad6..d13199ae0 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -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{