Add built-in Kimi provider support

This commit is contained in:
patriceckhart 2026-05-05 08:40:37 +02:00
parent ff1af01fd7
commit a41cda5093
17 changed files with 549 additions and 60 deletions

View file

@ -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"},

View file

@ -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:

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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'.

View file

@ -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" {

118
internal/auth/kimi.go Normal file
View file

@ -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))
}

View file

@ -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()

View file

@ -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.

View file

@ -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)
}

View file

@ -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(`<!doctype html><html lang="
` + logoTag + `
<h1><span class="zot">zot</span> login</h1>
<hr class="rule">
<p>paste an api key for anthropic or openai. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
<p>paste an api key for anthropic, openai, or kimi. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
<p>
<a href="/apikey?provider=anthropic">anthropic api key </a><br>
<a href="/apikey?provider=openai">openai api key </a>
<a href="/apikey?provider=openai">openai api key </a><br>
<a href="/apikey?provider=kimi">kimi api key </a>
</p>
<hr class="rule">
<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro), close this tab and run /login inside <span class="zot">zot</span>.</p>
<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside <span class="zot">zot</span>.</p>
</body></html>`))
func init() {

View file

@ -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
}

View file

@ -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",

View file

@ -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)

View file

@ -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 {

View file

@ -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