From a41cda50935ce121ff660ec58c7548eb76613596 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Tue, 5 May 2026 08:40:37 +0200 Subject: [PATCH] Add built-in Kimi provider support --- internal/agent/args.go | 2 +- internal/agent/build.go | 62 +++++++++++--- internal/agent/cli.go | 47 +++++------ internal/agent/config.go | 89 ++++++++++++++++++++ internal/agent/modelsync.go | 9 ++ internal/agent/modes/interactive.go | 26 ++++-- internal/agent/modes/login_dialog.go | 23 ++++-- internal/auth/kimi.go | 118 +++++++++++++++++++++++++++ internal/auth/manager.go | 49 ++++++++++- internal/auth/oauth.go | 8 ++ internal/auth/probe.go | 6 ++ internal/auth/server.go | 11 +-- internal/auth/store.go | 3 + internal/provider/models.go | 7 ++ internal/provider/openai.go | 42 +++++++++- internal/provider/usermodels.go | 2 + internal/tui/view.go | 105 ++++++++++++++++++++++++ 17 files changed, 549 insertions(+), 60 deletions(-) create mode 100644 internal/auth/kimi.go diff --git a/internal/agent/args.go b/internal/agent/args.go index 07eeb3a..7535441 100644 --- a/internal/agent/args.go +++ b/internal/agent/args.go @@ -334,7 +334,7 @@ func PrintHelp(version string) { row{"zot tg ...", "short alias for telegram-bot"}, ) section("provider and model flags", - row{"--provider", "provider to use (anthropic|openai|ollama)"}, + row{"--provider", "provider to use (anthropic|openai|kimi|ollama)"}, row{"--model ID", "model id (see --list-models)"}, row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, row{"--base-url URL", "override provider api base url"}, diff --git a/internal/agent/build.go b/internal/agent/build.go index d8cf61e..ff34623 100644 --- a/internal/agent/build.go +++ b/internal/agent/build.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "github.com/patriceckhart/zot/internal/agent/tools" @@ -128,13 +129,16 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { // User-requested provider (explicit > config > default). provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic") - if provName != "anthropic" && provName != "openai" && provName != "ollama" { + if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "ollama" { // Unknown provider (maybe removed or renamed). Fall back to // the first provider that has credentials, or anthropic. provName = "anthropic" if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil { provName = "openai" } + if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { + provName = "kimi" + } if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil { provName = "anthropic" } @@ -166,13 +170,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { // never shows a "not logged in" banner. userPickedProvider := args.Provider != "" if credErr != nil && !userPickedProvider && provName != "ollama" { - other := "openai" - if provName == "openai" { - other = "anthropic" - } - if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { - provName = other - cred, method, accountID, credErr = c, m, a, err + for _, other := range []string{"anthropic", "openai", "kimi"} { + if other == provName { + continue + } + if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { + provName = other + cred, method, accountID, credErr = c, m, a, err + break + } } } @@ -181,6 +187,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { switch provName { case "openai": model = "gpt-5" + case "kimi": + model = "kimi-for-coding" case "ollama": return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)") default: @@ -191,9 +199,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { // says gpt-5 but we fell back to anthropic), pick that provider's default. if provName != "ollama" { if m, err := provider.FindModel("", model); err == nil && m.Provider != provName { - if provName == "openai" { + switch provName { + case "openai": model = "gpt-5" - } else { + case "kimi": + model = "kimi-for-coding" + default: model = provider.DefaultModel.ID } } @@ -345,6 +356,8 @@ func (r Resolved) NewClient() provider.Client { switch r.Provider { case "ollama": return provider.NewOpenAI(r.Credential, r.BaseURL) + case "kimi": + return provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()) case "openai": if r.AuthMethod == "oauth" { inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) @@ -380,6 +393,8 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { switch provName { case "openai": return provider.NewOpenAICodex(token, accountID, baseURL) + case "kimi": + return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders()) default: return provider.NewAnthropicOAuth(token, baseURL) } @@ -464,10 +479,37 @@ func firstNonEmpty(vals ...string) string { return "" } +func kimiCodeHeaders() map[string]string { + host, _ := os.Hostname() + if host == "" { + host = "unknown" + } + deviceID := "" + if home, err := os.UserHomeDir(); err == nil { + if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil { + deviceID = strings.TrimSpace(string(b)) + } + } + if deviceID == "" { + deviceID = "zot" + } + return map[string]string{ + "User-Agent": "KimiCLI/1.41.0", + "X-Msh-Platform": "kimi_cli", + "X-Msh-Version": "1.41.0", + "X-Msh-Device-Name": host, + "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH, + "X-Msh-Os-Version": runtime.GOOS, + "X-Msh-Device-Id": deviceID, + } +} + func envVarName(provider string) string { switch provider { case "openai": return "OPENAI" + case "kimi": + return "KIMI" case "ollama": return "OLLAMA" default: diff --git a/internal/agent/cli.go b/internal/agent/cli.go index efe42e5..3132fa6 100644 --- a/internal/agent/cli.go +++ b/internal/agent/cli.go @@ -664,31 +664,32 @@ func runInteractive(ctx context.Context, args Args, version string) error { initialCfg, _ := LoadConfig() iv = modes.NewInteractive(modes.InteractiveConfig{ - Terminal: term, - Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), - InlineImagesEnabled: initialCfg.InlineImagesEnabled, - SettingsStore: configSettingsStore{}, - Model: r.Model, - Provider: r.Provider, - AuthMethod: r.AuthMethod, - BaseURL: r.BaseURL, - Reasoning: r.Reasoning, - SystemPrompt: r.SystemPrompt, - Tools: r.ToolRegistry, - MaxSteps: r.MaxSteps, - CWD: r.CWD, - ZotHome: ZotHome(), - Version: version, - UpdateInfoChan: updateCh, - Sandbox: sharedSandbox, - Agent: ag, - InitialInput: args.Prompt, - AuthManager: mgr, - BuildAgent: buildAgent, - BuildAgentFor: buildAgentFor, + Terminal: term, + Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), + InlineImagesEnabled: initialCfg.InlineImagesEnabled, + SettingsStore: configSettingsStore{}, + Model: r.Model, + Provider: r.Provider, + AuthMethod: r.AuthMethod, + BaseURL: r.BaseURL, + Reasoning: r.Reasoning, + SystemPrompt: r.SystemPrompt, + Tools: r.ToolRegistry, + MaxSteps: r.MaxSteps, + CWD: r.CWD, + ZotHome: ZotHome(), + Version: version, + UpdateInfoChan: updateCh, + Sandbox: sharedSandbox, + Agent: ag, + InitialInput: args.Prompt, + AuthManager: mgr, + BuildAgent: buildAgent, + SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, + BuildAgentFor: buildAgentFor, LoggedInProviders: func() []string { var out []string - for _, p := range []string{"anthropic", "openai"} { + for _, p := range []string{"anthropic", "openai", "kimi"} { if _, _, err := ResolveCredential(p, ""); err == nil { out = append(out, p) } diff --git a/internal/agent/config.go b/internal/agent/config.go index 48e38bb..a1e448d 100644 --- a/internal/agent/config.go +++ b/internal/agent/config.go @@ -68,6 +68,12 @@ func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") } // AuthPath returns the path to auth.json. func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } +// KimiCLIFallbackDisabledPath returns a sentinel that disables falling +// back to the official Kimi Code CLI token after `zot /logout kimi`. +func KimiCLIFallbackDisabledPath() string { + return filepath.Join(ZotHome(), "kimi-cli-fallback-disabled") +} + // SessionsPath returns the directory holding session files. func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") } @@ -135,6 +141,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s if v := os.Getenv("OPENAI_API_KEY"); v != "" { return v, "apikey", "", nil } + case "kimi": + if v := os.Getenv("KIMI_API_KEY"); v != "" { + return v, "apikey", "", nil + } + if v := os.Getenv("MOONSHOT_API_KEY"); v != "" { + return v, "apikey", "", nil + } } c, err := AuthStoreFor().Load() if err != nil { @@ -157,10 +170,76 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) return tok.AccessToken, "oauth", tok.AccountID, nil } + case "kimi": + if c.Kimi.APIKey != "" { + return c.Kimi.APIKey, "apikey", "", nil + } + if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" { + tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth) + return tok.AccessToken, "oauth", "", nil + } + if kimiCLIFallbackDisabled() { + break + } + if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" { + tok, _ = refreshIfExpired("kimi", tok) + return tok.AccessToken, "oauth", "", nil + } } return "", "", "", fmt.Errorf("no credential for %s", provider) } +func kimiCLIFallbackDisabled() bool { + _, err := os.Stat(KimiCLIFallbackDisabledPath()) + return err == nil +} + +func SetKimiCLIFallbackDisabled(disabled bool) error { + path := KimiCLIFallbackDisabledPath() + if !disabled { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte("disabled\n"), 0o600) +} + +func loadKimiCodeCLIToken() *auth.OAuthToken { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json")) + if err != nil { + return nil + } + var raw struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresAt float64 `json:"expires_at"` + Scope string `json:"scope"` + ExpiresIn float64 `json:"expires_in"` + } + if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" { + return nil + } + sec := int64(raw.ExpiresAt) + nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9) + return &auth.OAuthToken{ + AccessToken: raw.AccessToken, + RefreshToken: raw.RefreshToken, + TokenType: raw.TokenType, + Scope: raw.Scope, + ClientID: auth.KimiOAuth.ClientID, + Expiry: time.Unix(sec, nsec), + } +} + // loadOAuthToken reads the current OAuth token from auth.json for the // given provider. Returns nil if no token is stored. func loadOAuthToken(providerName string) *auth.OAuthToken { @@ -177,6 +256,14 @@ func loadOAuthToken(providerName string) *auth.OAuthToken { if c.OpenAI.OAuth != nil { return c.OpenAI.OAuth } + case "kimi": + if c.Kimi.OAuth != nil { + return c.Kimi.OAuth + } + if kimiCLIFallbackDisabled() { + return nil + } + return loadKimiCodeCLIToken() } return nil } @@ -205,6 +292,8 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok op = auth.AnthropicOAuth case "openai": op = auth.OpenAIOAuth + case "kimi": + op = auth.KimiOAuth default: return tok, fmt.Errorf("unknown provider %q", providerName) } diff --git a/internal/agent/modelsync.go b/internal/agent/modelsync.go index 358bd94..f57f97c 100644 --- a/internal/agent/modelsync.go +++ b/internal/agent/modelsync.go @@ -72,6 +72,15 @@ func refreshModels() { all = append(all, live...) } } + if cred, method, err := ResolveCredential("kimi", ""); err == nil && method == "apikey" { + if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil { + for i := range live { + live[i].Provider = "kimi" + live[i].Source = "live" + } + all = append(all, live...) + } + } if len(all) == 0 { return diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 4448d7d..db3c800 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -54,6 +54,10 @@ type InteractiveConfig struct { // the concrete provider/model in use. BuildAgent func() (*core.Agent, string, string, error) + // SetKimiCLIFallbackDisabled controls whether zot may fall back to + // the official Kimi Code CLI token when zot has no stored Kimi token. + SetKimiCLIFallbackDisabled func(disabled bool) error + // BuildAgentFor rebuilds the agent with an explicit provider/model // override (used by the /model picker when switching providers). // If providerOverride is empty, the current provider is kept. @@ -2397,7 +2401,7 @@ func (i *Interactive) openLogoutDialog() { } var items []logoutItem - for _, p := range []string{"anthropic", "openai"} { + for _, p := range []string{"anthropic", "openai", "kimi"} { if creds.Has(p) { method := creds.Method(p) if method == "oauth" { @@ -2431,7 +2435,7 @@ func (i *Interactive) openLogoutDialog() { // is torn down so the user is forced through /login before their next // prompt. // -// target: "anthropic" | "openai" | "all" +// target: "anthropic" | "openai" | "kimi" | "all" func (i *Interactive) doLogout(target string) { if i.cfg.AuthManager == nil { i.mu.Lock() @@ -2450,12 +2454,12 @@ func (i *Interactive) doLogout(target string) { var providers []string switch target { case "", "all": - providers = []string{"anthropic", "openai"} - case "anthropic", "openai": + providers = []string{"anthropic", "openai", "kimi"} + case "anthropic", "openai", "kimi": providers = []string{target} default: i.mu.Lock() - i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)" + i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, or all)" i.mu.Unlock() return } @@ -2467,6 +2471,12 @@ func (i *Interactive) doLogout(target string) { errs = append(errs, p+": "+err.Error()) continue } + if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { + if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil { + errs = append(errs, p+": "+err.Error()) + continue + } + } if p == i.cfg.Provider { clearedCurrent = true } @@ -2491,6 +2501,9 @@ func (i *Interactive) doLogout(target string) { } func (i *Interactive) startAPIKeyFlow(provider string) { + if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { + _ = i.cfg.SetKimiCLIFallbackDisabled(false) + } url, err := i.cfg.AuthManager.StartAPIKey(provider) if err != nil { i.dialog.ShowResult(false, err.Error()) @@ -2500,6 +2513,9 @@ func (i *Interactive) startAPIKeyFlow(provider string) { } func (i *Interactive) startOAuthFlow(provider string) { + if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { + _ = i.cfg.SetKimiCLIFallbackDisabled(false) + } // Always run the manual/copy-code flow in parallel with the local // callback server so headless environments (docker, SSH) can paste // the authorization code directly without first pressing 'p'. diff --git a/internal/agent/modes/login_dialog.go b/internal/agent/modes/login_dialog.go index 0de9143..aafda21 100644 --- a/internal/agent/modes/login_dialog.go +++ b/internal/agent/modes/login_dialog.go @@ -17,7 +17,7 @@ type loginStep int const ( loginStepClosed loginStep = iota loginStepMethod // pick apikey vs subscription - loginStepProvider // pick anthropic vs openai + loginStepProvider // pick anthropic vs openai vs kimi loginStepWaiting // browser open, waiting for callback loginStepPasteCode // user pastes the auth code here loginStepDone // success or error, waiting for key to dismiss @@ -28,7 +28,7 @@ const ( type loginDialog struct { step loginStep method string // "apikey" | "oauth" - provider string // "anthropic" | "openai" + provider string // "anthropic" | "openai" | "kimi" message string success bool url string @@ -39,7 +39,7 @@ type loginDialog struct { // provider, captured when Open() runs. Rendered above the // method picker so the user can see whether they're already // logged in (and how) before starting a new flow. Keys: - // "anthropic", "openai". Value is "apikey", "oauth", or "" + // "anthropic", "openai", "kimi". Value is "apikey", "oauth", or "" // (not logged in). status map[string]string } @@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) { d.success = false d.url = "" d.cursor = 0 - d.status = map[string]string{"anthropic": "", "openai": ""} + d.status = map[string]string{"anthropic": "", "openai": "", "kimi": ""} // Best-effort: if the auth file can't be read, treat every // provider as not-logged-in. The status line just won't show // anything useful in that case, which is fine — the user @@ -74,6 +74,7 @@ func (d *loginDialog) Open(zotHome string) { if creds, err := auth.NewStore(path).Load(); err == nil { d.status["anthropic"] = creds.Method("anthropic") d.status["openai"] = creds.Method("openai") + d.status["kimi"] = creds.Method("kimi") } } @@ -93,7 +94,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { case loginStepMethod: opts := []string{ "api key", - "subscription (claude pro/max - chatgpt plus/pro)", + "subscription (claude pro/max - chatgpt plus/pro - kimi code)", } lines = append(lines, frameHeader(th, "login", width)) for _, l := range d.renderStatusLines(th) { @@ -110,7 +111,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { } lines = append(lines, frameRule(th, width)) case loginStepProvider: - opts := []string{"anthropic", "openai"} + opts := []string{"anthropic", "openai", "kimi"} lines = append(lines, frameHeader(th, "login - "+d.method, width)) for _, l := range d.renderStatusLines(th) { lines = append(lines, l) @@ -201,6 +202,8 @@ func providerLabel(id string) string { return "Anthropic (Claude Pro/Max)" case "openai": return "OpenAI (ChatGPT Plus/Pro)" + case "kimi": + return "Kimi Code" } return id } @@ -218,7 +221,8 @@ func providerLabel(id string) string { func (d *loginDialog) renderStatusLines(th tui.Theme) []string { anth := d.status["anthropic"] op := d.status["openai"] - if anth == "" && op == "" { + kimi := d.status["kimi"] + if anth == "" && op == "" && kimi == "" { return nil } row := func(id, method string) string { @@ -240,6 +244,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { return []string{ row("anthropic", anth), row("openai", op), + row("kimi", kimi), "", } } @@ -305,14 +310,14 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { d.cursor-- } case tui.KeyDown: - if d.cursor < 1 { + if d.cursor < 2 { d.cursor++ } case tui.KeyEsc: d.Close() return loginDialogAction{Close: true} case tui.KeyEnter: - providers := []string{"anthropic", "openai"} + providers := []string{"anthropic", "openai", "kimi"} d.provider = providers[d.cursor] d.step = loginStepWaiting if d.method == "apikey" { diff --git a/internal/auth/kimi.go b/internal/auth/kimi.go new file mode 100644 index 0000000..8f5097a --- /dev/null +++ b/internal/auth/kimi.go @@ -0,0 +1,118 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// KimiDeviceAuthorization is the OAuth 2 device-code response used by +// the official Kimi Code CLI. +type KimiDeviceAuthorization struct { + UserCode string `json:"user_code"` + DeviceCode string `json:"device_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// RequestKimiDeviceAuthorization starts Kimi Code's device-code login. +func RequestKimiDeviceAuthorization(ctx context.Context) (KimiDeviceAuthorization, error) { + form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID) + req, err := http.NewRequestWithContext(ctx, "POST", "https://auth.kimi.com/api/oauth/device_authorization", form) + if err != nil { + return KimiDeviceAuthorization{}, err + } + req.Header.Set("content-type", "application/x-www-form-urlencoded") + req.Header.Set("accept", "application/json") + req.Header.Set("user-agent", "zot") + + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization http %d: %s", resp.StatusCode, string(body)) + } + var out KimiDeviceAuthorization + if err := json.Unmarshal(body, &out); err != nil { + return out, fmt.Errorf("parse kimi device authorization: %w", err) + } + if out.Interval <= 0 { + out.Interval = 5 + } + return out, nil +} + +// PollKimiDeviceToken polls until the browser/device-code login completes. +func PollKimiDeviceToken(ctx context.Context, auth KimiDeviceAuthorization) (*OAuthToken, error) { + interval := time.Duration(auth.Interval) * time.Second + if interval <= 0 { + interval = 5 * time.Second + } + for { + tok, retry, err := pollKimiDeviceTokenOnce(ctx, auth.DeviceCode) + if err != nil { + return nil, err + } + if tok != nil { + return tok, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(retry): + } + } +} + +func pollKimiDeviceTokenOnce(ctx context.Context, deviceCode string) (*OAuthToken, time.Duration, error) { + form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID + "&device_code=" + deviceCode + "&grant_type=urn:ietf:params:oauth:grant-type:device_code") + req, err := http.NewRequestWithContext(ctx, "POST", KimiOAuth.TokenURL, form) + if err != nil { + return nil, 0, err + } + req.Header.Set("content-type", "application/x-www-form-urlencoded") + req.Header.Set("accept", "application/json") + req.Header.Set("user-agent", "zot") + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return nil, 0, fmt.Errorf("kimi token poll: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var raw struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn float64 `json:"expires_in"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + _ = json.Unmarshal(body, &raw) + if resp.StatusCode == http.StatusOK && raw.AccessToken != "" { + return &OAuthToken{ + AccessToken: raw.AccessToken, + RefreshToken: raw.RefreshToken, + TokenType: raw.TokenType, + Scope: raw.Scope, + ClientID: KimiOAuth.ClientID, + Expiry: time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second), + }, 0, nil + } + if raw.Error == "authorization_pending" || raw.Error == "slow_down" || resp.StatusCode == http.StatusBadRequest { + return nil, 5 * time.Second, nil + } + if raw.Error != "" { + return nil, 0, fmt.Errorf("kimi token poll: %s: %s", raw.Error, raw.ErrorDescription) + } + return nil, 0, fmt.Errorf("kimi token poll http %d: %s", resp.StatusCode, string(body)) +} diff --git a/internal/auth/manager.go b/internal/auth/manager.go index a92a6f3..e00c8e3 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -78,8 +78,8 @@ func (m *Manager) Close() { // StartAPIKey launches the API-key login flow. func (m *Manager) StartAPIKey(provider string) (string, error) { - if provider != "anthropic" && provider != "openai" { - return "", fmt.Errorf("provider must be anthropic or openai") + if provider != "anthropic" && provider != "openai" && provider != "kimi" { + return "", fmt.Errorf("provider must be anthropic, openai, or kimi") } if err := m.ensureKeyServer(); err != nil { return "", err @@ -125,6 +125,9 @@ func (m *Manager) consumeKeyServerResults() { // Only one oauth flow may be in progress at a time (because the // callback port is fixed per provider and re-used by the official CLIs). func (m *Manager) StartOAuth(provider string) (string, error) { + if provider == "kimi" { + return m.StartKimiDeviceOAuth() + } var op OAuthProvider switch provider { case "anthropic": @@ -132,7 +135,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) { case "openai": op = OpenAIOAuth default: - return "", fmt.Errorf("provider must be anthropic or openai") + return "", fmt.Errorf("provider must be anthropic, openai, or kimi") } m.mu.Lock() @@ -203,11 +206,49 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) } +// StartKimiDeviceOAuth starts Kimi Code's device-code subscription login. +func (m *Manager) StartKimiDeviceOAuth() (string, error) { + ctx, cancel := context.WithCancel(context.Background()) + m.mu.Lock() + if m.oauthCancel != nil { + m.oauthCancel() + } + m.oauthCtx = ctx + m.oauthCancel = cancel + m.mu.Unlock() + + dev, err := RequestKimiDeviceAuthorization(ctx) + if err != nil { + return "", err + } + url := dev.VerificationURIComplete + go m.maybeOpen(url) + m.emit(Event{Kind: "started", Provider: "kimi", Method: "oauth", URL: url}) + go func() { + tok, err := PollKimiDeviceToken(ctx, dev) + if err != nil { + if ctx.Err() == nil { + m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()}) + } + return + } + if err := m.store.SetOAuth("kimi", *tok); err != nil { + m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()}) + return + } + m.emit(Event{Kind: "success", Provider: "kimi", Method: "oauth"}) + }() + return url, nil +} + // StartManualOAuth begins an OAuth flow but does NOT start a local // callback server or open a browser. The returned URL is shown to the // user so they can complete the authorization on another device; the // resulting code is pasted back via CompleteManualOAuth. func (m *Manager) StartManualOAuth(provider string) (string, error) { + if provider == "kimi" { + return m.StartKimiDeviceOAuth() + } var op OAuthProvider switch provider { case "anthropic": @@ -215,7 +256,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { case "openai": op = OpenAIOAuth default: - return "", fmt.Errorf("provider must be anthropic or openai") + return "", fmt.Errorf("provider must be anthropic, openai, or kimi") } pkce, err := NewPKCE() diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 75ed0e9..ff33980 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -111,6 +111,14 @@ var ( "originator": "zot", }, } + + // Kimi Code subscription: used by the official Kimi Code CLI. + // Kimi uses OAuth 2 device-code flow instead of a loopback callback. + KimiOAuth = OAuthProvider{ + Name: "kimi", + TokenURL: "https://auth.kimi.com/api/oauth/token", + ClientID: "17e5f671-d194-4dfb-9706-5516cb48c098", + } ) // PKCE holds a verifier/challenge pair. diff --git a/internal/auth/probe.go b/internal/auth/probe.go index 05e5c2c..464f1ee 100644 --- a/internal/auth/probe.go +++ b/internal/auth/probe.go @@ -31,6 +31,12 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { return err } req.Header.Set("authorization", "Bearer "+key) + case "kimi": + req, err = http.NewRequestWithContext(ctx, "GET", "https://api.kimi.com/coding/v1/models", nil) + if err != nil { + return err + } + req.Header.Set("authorization", "Bearer "+key) default: return fmt.Errorf("unknown provider %q", provider) } diff --git a/internal/auth/server.go b/internal/auth/server.go index 913dec5..09a1620 100644 --- a/internal/auth/server.go +++ b/internal/auth/server.go @@ -111,8 +111,8 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: provider := r.URL.Query().Get("provider") - if provider != "anthropic" && provider != "openai" { - http.Error(w, "provider must be anthropic or openai", http.StatusBadRequest) + if provider != "anthropic" && provider != "openai" && provider != "kimi" { + http.Error(w, "provider must be anthropic, openai, or kimi", http.StatusBadRequest) return } tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider}) @@ -223,13 +223,14 @@ var tpl = template.Must(template.New("index").Parse(`zot login
-

paste an api key for anthropic or openai. zot probes the provider once, then saves the key to ~/Library/Application Support/zot/auth.json.

+

paste an api key for anthropic, openai, or kimi. zot probes the provider once, then saves the key to ~/Library/Application Support/zot/auth.json.

anthropic api key →
- openai api key → + openai api key →
+ kimi api key →


-

for a subscription login (claude pro/max - chatgpt plus/pro), close this tab and run /login inside zot.

+

for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside zot.

`)) func init() { diff --git a/internal/auth/store.go b/internal/auth/store.go index deaffc0..5b989b0 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -18,6 +18,7 @@ import ( type Credentials struct { Anthropic ProviderCreds `json:"anthropic,omitempty"` OpenAI ProviderCreds `json:"openai,omitempty"` + Kimi ProviderCreds `json:"kimi,omitempty"` } // ProviderCreds holds credentials for a single provider. Only one of @@ -80,6 +81,8 @@ func (c *Credentials) get(provider string) *ProviderCreds { return &c.Anthropic case "openai": return &c.OpenAI + case "kimi": + return &c.Kimi } return nil } diff --git a/internal/provider/models.go b/internal/provider/models.go index 19850c2..a59a688 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -92,6 +92,13 @@ var Catalog = []Model{ PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75, }, + // ---- Kimi / Kimi Code ---- + { + Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi-k2.6", + ContextWindow: 262144, MaxOutput: 32000, Reasoning: true, + BaseURL: "https://api.kimi.com/coding/v1", + }, + // ---- OpenAI / GPT-5 family ---- { Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5", diff --git a/internal/provider/openai.go b/internal/provider/openai.go index 03011e6..5455b48 100644 --- a/internal/provider/openai.go +++ b/internal/provider/openai.go @@ -17,7 +17,9 @@ const openaiDefaultBaseURL = "https://api.openai.com" type openaiClient struct { apiKey string baseURL string + name string oauth bool // when true, apiKey actually holds an OAuth access token + headers map[string]string http *http.Client } @@ -29,6 +31,7 @@ func NewOpenAI(apiKey, baseURL string) Client { return &openaiClient{ apiKey: apiKey, baseURL: strings.TrimRight(baseURL, "/"), + name: "openai", http: &http.Client{Timeout: 0}, } } @@ -42,12 +45,38 @@ func NewOpenAIOAuth(accessToken, baseURL string) Client { return &openaiClient{ apiKey: accessToken, baseURL: strings.TrimRight(baseURL, "/"), + name: "openai", oauth: true, http: &http.Client{Timeout: 0}, } } -func (c *openaiClient) Name() string { return "openai" } +// NewKimi creates a Kimi/Moonshot client. Kimi's chat API is OpenAI-compatible. +func NewKimi(apiKey, baseURL string) Client { + return NewKimiWithHeaders(apiKey, baseURL, nil) +} + +// NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers. +// Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers. +func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client { + if baseURL == "" { + baseURL = "https://api.kimi.com/coding/v1" + } + return &openaiClient{ + apiKey: apiKey, + baseURL: strings.TrimRight(baseURL, "/"), + name: "kimi", + headers: headers, + http: &http.Client{Timeout: 0}, + } +} + +func (c *openaiClient) Name() string { + if c.name != "" { + return c.name + } + return "openai" +} // ---- wire types ---- @@ -285,6 +314,10 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} { // ---- streaming ---- func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { + apiPath := "/v1/chat/completions" + if strings.HasSuffix(c.baseURL, "/v1") { + apiPath = "/chat/completions" + } wire, err := c.buildRequest(req) if err != nil { return nil, err @@ -293,13 +326,16 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e if err != nil { return nil, err } - httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body)) if err != nil { return nil, err } httpReq.Header.Set("content-type", "application/json") httpReq.Header.Set("accept", "text/event-stream") httpReq.Header.Set("authorization", "Bearer "+c.apiKey) + for k, v := range c.headers { + httpReq.Header.Set(k, v) + } resp, err := c.http.Do(httpReq) if err != nil { @@ -321,7 +357,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R defer resp.Body.Close() model, _ := FindModel("", req.Model) - out <- EventStart{Model: req.Model, Provider: "openai"} + out <- EventStart{Model: req.Model, Provider: c.Name()} raw := make(chan sseEvent, 16) go readSSE(resp.Body, raw) diff --git a/internal/provider/usermodels.go b/internal/provider/usermodels.go index e7229f6..486ccc5 100644 --- a/internal/provider/usermodels.go +++ b/internal/provider/usermodels.go @@ -78,6 +78,8 @@ func LoadUserModels(path string) []Model { normalized = "openai" case "anthropic-messages": normalized = "anthropic" + case "moonshot", "moonshot-ai", "kimi-code": + normalized = "kimi" } for _, um := range prov.Models { diff --git a/internal/tui/view.go b/internal/tui/view.go index f2af22a..cf30648 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -7,7 +7,9 @@ import ( "regexp" "strconv" "strings" + "unicode/utf8" + "github.com/mattn/go-runewidth" "github.com/patriceckhart/zot/internal/provider" ) @@ -1440,6 +1442,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { // in muted type, everything else on the default tool-output color. // Called from renderToolText when the first line starts with "$ ". func (v *View) renderBashResult(lines []string, width, defaultColor int) []string { + lines = normalizeBashOutputLines(lines) // Identify the footer line (exit + timing). The bash tool writes // it as the last non-empty line of the result. footerIdx := -1 @@ -1477,6 +1480,108 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin return out } +// normalizeBashOutputLines turns arbitrary terminal output into plain, +// box-safe rows before wrapping. Unlike read/write/edit output, bash +// stdout/stderr may contain tabs, carriage returns, ANSI/OSC escapes, +// and other C0 controls from subprocesses. If those reach the bordered +// tool box, the width calculator can undercount what the terminal will +// draw and the right edge appears broken. Keep printable text, expand +// tabs to spaces, split carriage-return progress rows, and drop escape +// / control sequences. +func normalizeBashOutputLines(lines []string) []string { + var out []string + for _, line := range lines { + for _, part := range strings.Split(strings.ReplaceAll(line, "\r", "\n"), "\n") { + out = append(out, normalizeBashOutputLine(part)) + } + } + if len(out) == 0 { + return []string{""} + } + return out +} + +func normalizeBashOutputLine(s string) string { + var b strings.Builder + col := 0 + for i := 0; i < len(s); { + c := s[i] + if c == 0x1b { // ESC: strip CSI/OSC/DCS and simple escapes. + i = skipEscapeSequence(s, i) + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 1 { + i++ + continue + } + switch r { + case '\t': + spaces := 8 - (col % 8) + if spaces == 0 { + spaces = 8 + } + b.WriteString(strings.Repeat(" ", spaces)) + col += spaces + case '\b': + // Backspace-overstrike output is common in spinners/progress bars. + // Dropping it is safer than moving the TUI cursor backwards. + case '\n', '\r': + // Already split by normalizeBashOutputLines. + default: + if r < 0x20 || r == 0x7f { + // Drop non-printing controls. + break + } + b.WriteRune(r) + col += runewidth.RuneWidth(r) + } + i += size + } + return b.String() +} + +func skipEscapeSequence(s string, i int) int { + if i >= len(s) || s[i] != 0x1b { + return i + 1 + } + if i+1 >= len(s) { + return len(s) + } + switch s[i+1] { + case '[': // CSI: ESC [ ... final byte 0x40-0x7e + j := i + 2 + for j < len(s) { + c := s[j] + j++ + if c >= 0x40 && c <= 0x7e { + break + } + } + return j + case ']': // OSC: ESC ] ... BEL or ST + return skipStringEscape(s, i+2) + case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc. + return skipStringEscape(s, i+2) + default: + // Two-byte escape (cursor save/restore, charset select, etc.). + return i + 2 + } +} + +func skipStringEscape(s string, i int) int { + for i < len(s) { + if s[i] == 0x07 { // BEL + return i + 1 + } + if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST + return i + 2 + } + i++ + } + return len(s) +} + // looksLikeUnifiedDiff reports whether text is a context diff as // emitted by the edit tool: rows start with '+', '-', ' ', or // literal "..." (context-break marker). The presence of at least