Add built-in Kimi provider support
- commit
- a41cda50935ce121ff660ec58c7548eb76613596
- author
- patriceckhart · · 2026-05-05 06:40 UTC
- parent
- ff1af01
- signature
- unsigned
17 files · +549 -60
M internal/agent/args.go +1 -1
| @@ -334,7 +334,7 @@ func PrintHelp(version string) { | |||
| 334 | 334 | row{"zot tg ...", "short alias for telegram-bot"}, | |
| 335 | 335 | ) | |
| 336 | 336 | section("provider and model flags", | |
| 337 | - | row{"--provider", "provider to use (anthropic|openai|ollama)"}, | |
| 337 | + | row{"--provider", "provider to use (anthropic|openai|kimi|ollama)"}, | |
| 338 | 338 | row{"--model ID", "model id (see --list-models)"}, | |
| 339 | 339 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | |
| 340 | 340 | row{"--base-url URL", "override provider api base url"}, | |
| @@ -334,7 +334,7 @@ func PrintHelp(version string) { | |||||
| 334 | row{"zot tg ...", "short alias for telegram-bot"}, | 334 | row{"zot tg ...", "short alias for telegram-bot"}, | ||
| 335 | ) | 335 | ) | ||
| 336 | section("provider and model flags", | 336 | section("provider and model flags", | ||
| 337 | - | row{"--provider", "provider to use (anthropic|openai|ollama)"}, | 337 | + | row{"--provider", "provider to use (anthropic|openai|kimi|ollama)"}, |
| 338 | row{"--model ID", "model id (see --list-models)"}, | 338 | row{"--model ID", "model id (see --list-models)"}, | ||
| 339 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | 339 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | ||
| 340 | row{"--base-url URL", "override provider api base url"}, | 340 | row{"--base-url URL", "override provider api base url"}, | ||
M internal/agent/build.go +52 -10
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "runtime" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/patriceckhart/zot/internal/agent/tools" | |
| @@ -128,13 +129,16 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 128 | 129 | ||
| 129 | 130 | // User-requested provider (explicit > config > default). | |
| 130 | 131 | provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic") | |
| 131 | - | if provName != "anthropic" && provName != "openai" && provName != "ollama" { | |
| 132 | + | if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "ollama" { | |
| 132 | 133 | // Unknown provider (maybe removed or renamed). Fall back to | |
| 133 | 134 | // the first provider that has credentials, or anthropic. | |
| 134 | 135 | provName = "anthropic" | |
| 135 | 136 | if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil { | |
| 136 | 137 | provName = "openai" | |
| 137 | 138 | } | |
| 139 | + | if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { | |
| 140 | + | provName = "kimi" | |
| 141 | + | } | |
| 138 | 142 | if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil { | |
| 139 | 143 | provName = "anthropic" | |
| 140 | 144 | } | |
| @@ -166,13 +170,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 166 | 170 | // never shows a "not logged in" banner. | |
| 167 | 171 | userPickedProvider := args.Provider != "" | |
| 168 | 172 | if credErr != nil && !userPickedProvider && provName != "ollama" { | |
| 169 | - | other := "openai" | |
| 170 | - | if provName == "openai" { | |
| 171 | - | other = "anthropic" | |
| 172 | - | } | |
| 173 | - | if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { | |
| 174 | - | provName = other | |
| 175 | - | cred, method, accountID, credErr = c, m, a, err | |
| 173 | + | for _, other := range []string{"anthropic", "openai", "kimi"} { | |
| 174 | + | if other == provName { | |
| 175 | + | continue | |
| 176 | + | } | |
| 177 | + | if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { | |
| 178 | + | provName = other | |
| 179 | + | cred, method, accountID, credErr = c, m, a, err | |
| 180 | + | break | |
| 181 | + | } | |
| 176 | 182 | } | |
| 177 | 183 | } | |
| 178 | 184 | ||
| @@ -181,6 +187,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 181 | 187 | switch provName { | |
| 182 | 188 | case "openai": | |
| 183 | 189 | model = "gpt-5" | |
| 190 | + | case "kimi": | |
| 191 | + | model = "kimi-for-coding" | |
| 184 | 192 | case "ollama": | |
| 185 | 193 | return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)") | |
| 186 | 194 | default: | |
| @@ -191,9 +199,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 191 | 199 | // says gpt-5 but we fell back to anthropic), pick that provider's default. | |
| 192 | 200 | if provName != "ollama" { | |
| 193 | 201 | if m, err := provider.FindModel("", model); err == nil && m.Provider != provName { | |
| 194 | - | if provName == "openai" { | |
| 202 | + | switch provName { | |
| 203 | + | case "openai": | |
| 195 | 204 | model = "gpt-5" | |
| 196 | - | } else { | |
| 205 | + | case "kimi": | |
| 206 | + | model = "kimi-for-coding" | |
| 207 | + | default: | |
| 197 | 208 | model = provider.DefaultModel.ID | |
| 198 | 209 | } | |
| 199 | 210 | } | |
| @@ -345,6 +356,8 @@ func (r Resolved) NewClient() provider.Client { | |||
| 345 | 356 | switch r.Provider { | |
| 346 | 357 | case "ollama": | |
| 347 | 358 | return provider.NewOpenAI(r.Credential, r.BaseURL) | |
| 359 | + | case "kimi": | |
| 360 | + | return provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()) | |
| 348 | 361 | case "openai": | |
| 349 | 362 | if r.AuthMethod == "oauth" { | |
| 350 | 363 | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | |
| @@ -380,6 +393,8 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { | |||
| 380 | 393 | switch provName { | |
| 381 | 394 | case "openai": | |
| 382 | 395 | return provider.NewOpenAICodex(token, accountID, baseURL) | |
| 396 | + | case "kimi": | |
| 397 | + | return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders()) | |
| 383 | 398 | default: | |
| 384 | 399 | return provider.NewAnthropicOAuth(token, baseURL) | |
| 385 | 400 | } | |
| @@ -464,10 +479,37 @@ func firstNonEmpty(vals ...string) string { | |||
| 464 | 479 | return "" | |
| 465 | 480 | } | |
| 466 | 481 | ||
| 482 | + | func kimiCodeHeaders() map[string]string { | |
| 483 | + | host, _ := os.Hostname() | |
| 484 | + | if host == "" { | |
| 485 | + | host = "unknown" | |
| 486 | + | } | |
| 487 | + | deviceID := "" | |
| 488 | + | if home, err := os.UserHomeDir(); err == nil { | |
| 489 | + | if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil { | |
| 490 | + | deviceID = strings.TrimSpace(string(b)) | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | if deviceID == "" { | |
| 494 | + | deviceID = "zot" | |
| 495 | + | } | |
| 496 | + | return map[string]string{ | |
| 497 | + | "User-Agent": "KimiCLI/1.41.0", | |
| 498 | + | "X-Msh-Platform": "kimi_cli", | |
| 499 | + | "X-Msh-Version": "1.41.0", | |
| 500 | + | "X-Msh-Device-Name": host, | |
| 501 | + | "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH, | |
| 502 | + | "X-Msh-Os-Version": runtime.GOOS, | |
| 503 | + | "X-Msh-Device-Id": deviceID, | |
| 504 | + | } | |
| 505 | + | } | |
| 506 | + | ||
| 467 | 507 | func envVarName(provider string) string { | |
| 468 | 508 | switch provider { | |
| 469 | 509 | case "openai": | |
| 470 | 510 | return "OPENAI" | |
| 511 | + | case "kimi": | |
| 512 | + | return "KIMI" | |
| 471 | 513 | case "ollama": | |
| 472 | 514 | return "OLLAMA" | |
| 473 | 515 | default: | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "runtime" | |||
| 8 | "strings" | 9 | "strings" | ||
| 9 | 10 | ||||
| 10 | "github.com/patriceckhart/zot/internal/agent/tools" | 11 | "github.com/patriceckhart/zot/internal/agent/tools" | ||
| @@ -128,13 +129,16 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 128 | 129 | ||||
| 129 | // User-requested provider (explicit > config > default). | 130 | // User-requested provider (explicit > config > default). | ||
| 130 | provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic") | 131 | provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic") | ||
| 131 | - | if provName != "anthropic" && provName != "openai" && provName != "ollama" { | 132 | + | if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "ollama" { |
| 132 | // Unknown provider (maybe removed or renamed). Fall back to | 133 | // Unknown provider (maybe removed or renamed). Fall back to | ||
| 133 | // the first provider that has credentials, or anthropic. | 134 | // the first provider that has credentials, or anthropic. | ||
| 134 | provName = "anthropic" | 135 | provName = "anthropic" | ||
| 135 | if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil { | 136 | if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil { | ||
| 136 | provName = "openai" | 137 | provName = "openai" | ||
| 137 | } | 138 | } | ||
| 139 | + | if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { | |||
| 140 | + | provName = "kimi" | |||
| 141 | + | } | |||
| 138 | if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil { | 142 | if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil { | ||
| 139 | provName = "anthropic" | 143 | provName = "anthropic" | ||
| 140 | } | 144 | } | ||
| @@ -166,13 +170,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 166 | // never shows a "not logged in" banner. | 170 | // never shows a "not logged in" banner. | ||
| 167 | userPickedProvider := args.Provider != "" | 171 | userPickedProvider := args.Provider != "" | ||
| 168 | if credErr != nil && !userPickedProvider && provName != "ollama" { | 172 | if credErr != nil && !userPickedProvider && provName != "ollama" { | ||
| 169 | - | other := "openai" | 173 | + | for _, other := range []string{"anthropic", "openai", "kimi"} { |
| 170 | - | if provName == "openai" { | 174 | + | if other == provName { |
| 171 | - | other = "anthropic" | 175 | + | continue |
| 172 | - | } | 176 | + | } |
| 173 | - | if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { | 177 | + | if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { |
| 174 | - | provName = other | 178 | + | provName = other |
| 175 | - | cred, method, accountID, credErr = c, m, a, err | 179 | + | cred, method, accountID, credErr = c, m, a, err |
| 180 | + | break | |||
| 181 | + | } | |||
| 176 | } | 182 | } | ||
| 177 | } | 183 | } | ||
| 178 | 184 | ||||
| @@ -181,6 +187,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 181 | switch provName { | 187 | switch provName { | ||
| 182 | case "openai": | 188 | case "openai": | ||
| 183 | model = "gpt-5" | 189 | model = "gpt-5" | ||
| 190 | + | case "kimi": | |||
| 191 | + | model = "kimi-for-coding" | |||
| 184 | case "ollama": | 192 | case "ollama": | ||
| 185 | return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)") | 193 | return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)") | ||
| 186 | default: | 194 | default: | ||
| @@ -191,9 +199,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 191 | // says gpt-5 but we fell back to anthropic), pick that provider's default. | 199 | // says gpt-5 but we fell back to anthropic), pick that provider's default. | ||
| 192 | if provName != "ollama" { | 200 | if provName != "ollama" { | ||
| 193 | if m, err := provider.FindModel("", model); err == nil && m.Provider != provName { | 201 | if m, err := provider.FindModel("", model); err == nil && m.Provider != provName { | ||
| 194 | - | if provName == "openai" { | 202 | + | switch provName { |
| 203 | + | case "openai": | |||
| 195 | model = "gpt-5" | 204 | model = "gpt-5" | ||
| 196 | - | } else { | 205 | + | case "kimi": |
| 206 | + | model = "kimi-for-coding" | |||
| 207 | + | default: | |||
| 197 | model = provider.DefaultModel.ID | 208 | model = provider.DefaultModel.ID | ||
| 198 | } | 209 | } | ||
| 199 | } | 210 | } | ||
| @@ -345,6 +356,8 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 345 | switch r.Provider { | 356 | switch r.Provider { | ||
| 346 | case "ollama": | 357 | case "ollama": | ||
| 347 | return provider.NewOpenAI(r.Credential, r.BaseURL) | 358 | return provider.NewOpenAI(r.Credential, r.BaseURL) | ||
| 359 | + | case "kimi": | |||
| 360 | + | return provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()) | |||
| 348 | case "openai": | 361 | case "openai": | ||
| 349 | if r.AuthMethod == "oauth" { | 362 | if r.AuthMethod == "oauth" { | ||
| 350 | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | 363 | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | ||
| @@ -380,6 +393,8 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { | |||||
| 380 | switch provName { | 393 | switch provName { | ||
| 381 | case "openai": | 394 | case "openai": | ||
| 382 | return provider.NewOpenAICodex(token, accountID, baseURL) | 395 | return provider.NewOpenAICodex(token, accountID, baseURL) | ||
| 396 | + | case "kimi": | |||
| 397 | + | return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders()) | |||
| 383 | default: | 398 | default: | ||
| 384 | return provider.NewAnthropicOAuth(token, baseURL) | 399 | return provider.NewAnthropicOAuth(token, baseURL) | ||
| 385 | } | 400 | } | ||
| @@ -464,10 +479,37 @@ func firstNonEmpty(vals ...string) string { | |||||
| 464 | return "" | 479 | return "" | ||
| 465 | } | 480 | } | ||
| 466 | 481 | ||||
| 482 | + | func kimiCodeHeaders() map[string]string { | |||
| 483 | + | host, _ := os.Hostname() | |||
| 484 | + | if host == "" { | |||
| 485 | + | host = "unknown" | |||
| 486 | + | } | |||
| 487 | + | deviceID := "" | |||
| 488 | + | if home, err := os.UserHomeDir(); err == nil { | |||
| 489 | + | if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil { | |||
| 490 | + | deviceID = strings.TrimSpace(string(b)) | |||
| 491 | + | } | |||
| 492 | + | } | |||
| 493 | + | if deviceID == "" { | |||
| 494 | + | deviceID = "zot" | |||
| 495 | + | } | |||
| 496 | + | return map[string]string{ | |||
| 497 | + | "User-Agent": "KimiCLI/1.41.0", | |||
| 498 | + | "X-Msh-Platform": "kimi_cli", | |||
| 499 | + | "X-Msh-Version": "1.41.0", | |||
| 500 | + | "X-Msh-Device-Name": host, | |||
| 501 | + | "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH, | |||
| 502 | + | "X-Msh-Os-Version": runtime.GOOS, | |||
| 503 | + | "X-Msh-Device-Id": deviceID, | |||
| 504 | + | } | |||
| 505 | + | } | |||
| 506 | + | ||||
| 467 | func envVarName(provider string) string { | 507 | func envVarName(provider string) string { | ||
| 468 | switch provider { | 508 | switch provider { | ||
| 469 | case "openai": | 509 | case "openai": | ||
| 470 | return "OPENAI" | 510 | return "OPENAI" | ||
| 511 | + | case "kimi": | |||
| 512 | + | return "KIMI" | |||
| 471 | case "ollama": | 513 | case "ollama": | ||
| 472 | return "OLLAMA" | 514 | return "OLLAMA" | ||
| 473 | default: | 515 | default: | ||
M internal/agent/cli.go +24 -23
| @@ -664,31 +664,32 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 664 | 664 | ||
| 665 | 665 | initialCfg, _ := LoadConfig() | |
| 666 | 666 | iv = modes.NewInteractive(modes.InteractiveConfig{ | |
| 667 | - | Terminal: term, | |
| 668 | - | Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), | |
| 669 | - | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 670 | - | SettingsStore: configSettingsStore{}, | |
| 671 | - | Model: r.Model, | |
| 672 | - | Provider: r.Provider, | |
| 673 | - | AuthMethod: r.AuthMethod, | |
| 674 | - | BaseURL: r.BaseURL, | |
| 675 | - | Reasoning: r.Reasoning, | |
| 676 | - | SystemPrompt: r.SystemPrompt, | |
| 677 | - | Tools: r.ToolRegistry, | |
| 678 | - | MaxSteps: r.MaxSteps, | |
| 679 | - | CWD: r.CWD, | |
| 680 | - | ZotHome: ZotHome(), | |
| 681 | - | Version: version, | |
| 682 | - | UpdateInfoChan: updateCh, | |
| 683 | - | Sandbox: sharedSandbox, | |
| 684 | - | Agent: ag, | |
| 685 | - | InitialInput: args.Prompt, | |
| 686 | - | AuthManager: mgr, | |
| 687 | - | BuildAgent: buildAgent, | |
| 688 | - | BuildAgentFor: buildAgentFor, | |
| 667 | + | Terminal: term, | |
| 668 | + | Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), | |
| 669 | + | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 670 | + | SettingsStore: configSettingsStore{}, | |
| 671 | + | Model: r.Model, | |
| 672 | + | Provider: r.Provider, | |
| 673 | + | AuthMethod: r.AuthMethod, | |
| 674 | + | BaseURL: r.BaseURL, | |
| 675 | + | Reasoning: r.Reasoning, | |
| 676 | + | SystemPrompt: r.SystemPrompt, | |
| 677 | + | Tools: r.ToolRegistry, | |
| 678 | + | MaxSteps: r.MaxSteps, | |
| 679 | + | CWD: r.CWD, | |
| 680 | + | ZotHome: ZotHome(), | |
| 681 | + | Version: version, | |
| 682 | + | UpdateInfoChan: updateCh, | |
| 683 | + | Sandbox: sharedSandbox, | |
| 684 | + | Agent: ag, | |
| 685 | + | InitialInput: args.Prompt, | |
| 686 | + | AuthManager: mgr, | |
| 687 | + | BuildAgent: buildAgent, | |
| 688 | + | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | |
| 689 | + | BuildAgentFor: buildAgentFor, | |
| 689 | 690 | LoggedInProviders: func() []string { | |
| 690 | 691 | var out []string | |
| 691 | - | for _, p := range []string{"anthropic", "openai"} { | |
| 692 | + | for _, p := range []string{"anthropic", "openai", "kimi"} { | |
| 692 | 693 | if _, _, err := ResolveCredential(p, ""); err == nil { | |
| 693 | 694 | out = append(out, p) | |
| 694 | 695 | } | |
| @@ -664,31 +664,32 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 664 | 664 | ||||
| 665 | initialCfg, _ := LoadConfig() | 665 | initialCfg, _ := LoadConfig() | ||
| 666 | iv = modes.NewInteractive(modes.InteractiveConfig{ | 666 | iv = modes.NewInteractive(modes.InteractiveConfig{ | ||
| 667 | - | Terminal: term, | 667 | + | Terminal: term, |
| 668 | - | Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), | 668 | + | Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), |
| 669 | - | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | 669 | + | InlineImagesEnabled: initialCfg.InlineImagesEnabled, |
| 670 | - | SettingsStore: configSettingsStore{}, | 670 | + | SettingsStore: configSettingsStore{}, |
| 671 | - | Model: r.Model, | 671 | + | Model: r.Model, |
| 672 | - | Provider: r.Provider, | 672 | + | Provider: r.Provider, |
| 673 | - | AuthMethod: r.AuthMethod, | 673 | + | AuthMethod: r.AuthMethod, |
| 674 | - | BaseURL: r.BaseURL, | 674 | + | BaseURL: r.BaseURL, |
| 675 | - | Reasoning: r.Reasoning, | 675 | + | Reasoning: r.Reasoning, |
| 676 | - | SystemPrompt: r.SystemPrompt, | 676 | + | SystemPrompt: r.SystemPrompt, |
| 677 | - | Tools: r.ToolRegistry, | 677 | + | Tools: r.ToolRegistry, |
| 678 | - | MaxSteps: r.MaxSteps, | 678 | + | MaxSteps: r.MaxSteps, |
| 679 | - | CWD: r.CWD, | 679 | + | CWD: r.CWD, |
| 680 | - | ZotHome: ZotHome(), | 680 | + | ZotHome: ZotHome(), |
| 681 | - | Version: version, | 681 | + | Version: version, |
| 682 | - | UpdateInfoChan: updateCh, | 682 | + | UpdateInfoChan: updateCh, |
| 683 | - | Sandbox: sharedSandbox, | 683 | + | Sandbox: sharedSandbox, |
| 684 | - | Agent: ag, | 684 | + | Agent: ag, |
| 685 | - | InitialInput: args.Prompt, | 685 | + | InitialInput: args.Prompt, |
| 686 | - | AuthManager: mgr, | 686 | + | AuthManager: mgr, |
| 687 | - | BuildAgent: buildAgent, | 687 | + | BuildAgent: buildAgent, |
| 688 | - | BuildAgentFor: buildAgentFor, | 688 | + | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, |
| 689 | + | BuildAgentFor: buildAgentFor, | |||
| 689 | LoggedInProviders: func() []string { | 690 | LoggedInProviders: func() []string { | ||
| 690 | var out []string | 691 | var out []string | ||
| 691 | - | for _, p := range []string{"anthropic", "openai"} { | 692 | + | for _, p := range []string{"anthropic", "openai", "kimi"} { |
| 692 | if _, _, err := ResolveCredential(p, ""); err == nil { | 693 | if _, _, err := ResolveCredential(p, ""); err == nil { | ||
| 693 | out = append(out, p) | 694 | out = append(out, p) | ||
| 694 | } | 695 | } | ||
M internal/agent/config.go +89 -0
| @@ -68,6 +68,12 @@ func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") } | |||
| 68 | 68 | // AuthPath returns the path to auth.json. | |
| 69 | 69 | func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } | |
| 70 | 70 | ||
| 71 | + | // KimiCLIFallbackDisabledPath returns a sentinel that disables falling | |
| 72 | + | // back to the official Kimi Code CLI token after `zot /logout kimi`. | |
| 73 | + | func KimiCLIFallbackDisabledPath() string { | |
| 74 | + | return filepath.Join(ZotHome(), "kimi-cli-fallback-disabled") | |
| 75 | + | } | |
| 76 | + | ||
| 71 | 77 | // SessionsPath returns the directory holding session files. | |
| 72 | 78 | func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") } | |
| 73 | 79 | ||
| @@ -135,6 +141,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 135 | 141 | if v := os.Getenv("OPENAI_API_KEY"); v != "" { | |
| 136 | 142 | return v, "apikey", "", nil | |
| 137 | 143 | } | |
| 144 | + | case "kimi": | |
| 145 | + | if v := os.Getenv("KIMI_API_KEY"); v != "" { | |
| 146 | + | return v, "apikey", "", nil | |
| 147 | + | } | |
| 148 | + | if v := os.Getenv("MOONSHOT_API_KEY"); v != "" { | |
| 149 | + | return v, "apikey", "", nil | |
| 150 | + | } | |
| 138 | 151 | } | |
| 139 | 152 | c, err := AuthStoreFor().Load() | |
| 140 | 153 | if err != nil { | |
| @@ -157,10 +170,76 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 157 | 170 | tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) | |
| 158 | 171 | return tok.AccessToken, "oauth", tok.AccountID, nil | |
| 159 | 172 | } | |
| 173 | + | case "kimi": | |
| 174 | + | if c.Kimi.APIKey != "" { | |
| 175 | + | return c.Kimi.APIKey, "apikey", "", nil | |
| 176 | + | } | |
| 177 | + | if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" { | |
| 178 | + | tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth) | |
| 179 | + | return tok.AccessToken, "oauth", "", nil | |
| 180 | + | } | |
| 181 | + | if kimiCLIFallbackDisabled() { | |
| 182 | + | break | |
| 183 | + | } | |
| 184 | + | if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" { | |
| 185 | + | tok, _ = refreshIfExpired("kimi", tok) | |
| 186 | + | return tok.AccessToken, "oauth", "", nil | |
| 187 | + | } | |
| 160 | 188 | } | |
| 161 | 189 | return "", "", "", fmt.Errorf("no credential for %s", provider) | |
| 162 | 190 | } | |
| 163 | 191 | ||
| 192 | + | func kimiCLIFallbackDisabled() bool { | |
| 193 | + | _, err := os.Stat(KimiCLIFallbackDisabledPath()) | |
| 194 | + | return err == nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func SetKimiCLIFallbackDisabled(disabled bool) error { | |
| 198 | + | path := KimiCLIFallbackDisabledPath() | |
| 199 | + | if !disabled { | |
| 200 | + | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 201 | + | return err | |
| 202 | + | } | |
| 203 | + | return nil | |
| 204 | + | } | |
| 205 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 206 | + | return err | |
| 207 | + | } | |
| 208 | + | return os.WriteFile(path, []byte("disabled\n"), 0o600) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func loadKimiCodeCLIToken() *auth.OAuthToken { | |
| 212 | + | home, err := os.UserHomeDir() | |
| 213 | + | if err != nil { | |
| 214 | + | return nil | |
| 215 | + | } | |
| 216 | + | b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json")) | |
| 217 | + | if err != nil { | |
| 218 | + | return nil | |
| 219 | + | } | |
| 220 | + | var raw struct { | |
| 221 | + | AccessToken string `json:"access_token"` | |
| 222 | + | RefreshToken string `json:"refresh_token"` | |
| 223 | + | TokenType string `json:"token_type"` | |
| 224 | + | ExpiresAt float64 `json:"expires_at"` | |
| 225 | + | Scope string `json:"scope"` | |
| 226 | + | ExpiresIn float64 `json:"expires_in"` | |
| 227 | + | } | |
| 228 | + | if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" { | |
| 229 | + | return nil | |
| 230 | + | } | |
| 231 | + | sec := int64(raw.ExpiresAt) | |
| 232 | + | nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9) | |
| 233 | + | return &auth.OAuthToken{ | |
| 234 | + | AccessToken: raw.AccessToken, | |
| 235 | + | RefreshToken: raw.RefreshToken, | |
| 236 | + | TokenType: raw.TokenType, | |
| 237 | + | Scope: raw.Scope, | |
| 238 | + | ClientID: auth.KimiOAuth.ClientID, | |
| 239 | + | Expiry: time.Unix(sec, nsec), | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 164 | 243 | // loadOAuthToken reads the current OAuth token from auth.json for the | |
| 165 | 244 | // given provider. Returns nil if no token is stored. | |
| 166 | 245 | func loadOAuthToken(providerName string) *auth.OAuthToken { | |
| @@ -177,6 +256,14 @@ func loadOAuthToken(providerName string) *auth.OAuthToken { | |||
| 177 | 256 | if c.OpenAI.OAuth != nil { | |
| 178 | 257 | return c.OpenAI.OAuth | |
| 179 | 258 | } | |
| 259 | + | case "kimi": | |
| 260 | + | if c.Kimi.OAuth != nil { | |
| 261 | + | return c.Kimi.OAuth | |
| 262 | + | } | |
| 263 | + | if kimiCLIFallbackDisabled() { | |
| 264 | + | return nil | |
| 265 | + | } | |
| 266 | + | return loadKimiCodeCLIToken() | |
| 180 | 267 | } | |
| 181 | 268 | return nil | |
| 182 | 269 | } | |
| @@ -205,6 +292,8 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok | |||
| 205 | 292 | op = auth.AnthropicOAuth | |
| 206 | 293 | case "openai": | |
| 207 | 294 | op = auth.OpenAIOAuth | |
| 295 | + | case "kimi": | |
| 296 | + | op = auth.KimiOAuth | |
| 208 | 297 | default: | |
| 209 | 298 | return tok, fmt.Errorf("unknown provider %q", providerName) | |
| 210 | 299 | } | |
| @@ -68,6 +68,12 @@ func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") } | |||||
| 68 | // AuthPath returns the path to auth.json. | 68 | // AuthPath returns the path to auth.json. | ||
| 69 | func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } | 69 | func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } | ||
| 70 | 70 | ||||
| 71 | + | // KimiCLIFallbackDisabledPath returns a sentinel that disables falling | |||
| 72 | + | // back to the official Kimi Code CLI token after `zot /logout kimi`. | |||
| 73 | + | func KimiCLIFallbackDisabledPath() string { | |||
| 74 | + | return filepath.Join(ZotHome(), "kimi-cli-fallback-disabled") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 71 | // SessionsPath returns the directory holding session files. | 77 | // SessionsPath returns the directory holding session files. | ||
| 72 | func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") } | 78 | func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") } | ||
| 73 | 79 | ||||
| @@ -135,6 +141,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||||
| 135 | if v := os.Getenv("OPENAI_API_KEY"); v != "" { | 141 | if v := os.Getenv("OPENAI_API_KEY"); v != "" { | ||
| 136 | return v, "apikey", "", nil | 142 | return v, "apikey", "", nil | ||
| 137 | } | 143 | } | ||
| 144 | + | case "kimi": | |||
| 145 | + | if v := os.Getenv("KIMI_API_KEY"); v != "" { | |||
| 146 | + | return v, "apikey", "", nil | |||
| 147 | + | } | |||
| 148 | + | if v := os.Getenv("MOONSHOT_API_KEY"); v != "" { | |||
| 149 | + | return v, "apikey", "", nil | |||
| 150 | + | } | |||
| 138 | } | 151 | } | ||
| 139 | c, err := AuthStoreFor().Load() | 152 | c, err := AuthStoreFor().Load() | ||
| 140 | if err != nil { | 153 | if err != nil { | ||
| @@ -157,10 +170,76 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||||
| 157 | tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) | 170 | tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) | ||
| 158 | return tok.AccessToken, "oauth", tok.AccountID, nil | 171 | return tok.AccessToken, "oauth", tok.AccountID, nil | ||
| 159 | } | 172 | } | ||
| 173 | + | case "kimi": | |||
| 174 | + | if c.Kimi.APIKey != "" { | |||
| 175 | + | return c.Kimi.APIKey, "apikey", "", nil | |||
| 176 | + | } | |||
| 177 | + | if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" { | |||
| 178 | + | tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth) | |||
| 179 | + | return tok.AccessToken, "oauth", "", nil | |||
| 180 | + | } | |||
| 181 | + | if kimiCLIFallbackDisabled() { | |||
| 182 | + | break | |||
| 183 | + | } | |||
| 184 | + | if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" { | |||
| 185 | + | tok, _ = refreshIfExpired("kimi", tok) | |||
| 186 | + | return tok.AccessToken, "oauth", "", nil | |||
| 187 | + | } | |||
| 160 | } | 188 | } | ||
| 161 | return "", "", "", fmt.Errorf("no credential for %s", provider) | 189 | return "", "", "", fmt.Errorf("no credential for %s", provider) | ||
| 162 | } | 190 | } | ||
| 163 | 191 | ||||
| 192 | + | func kimiCLIFallbackDisabled() bool { | |||
| 193 | + | _, err := os.Stat(KimiCLIFallbackDisabledPath()) | |||
| 194 | + | return err == nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func SetKimiCLIFallbackDisabled(disabled bool) error { | |||
| 198 | + | path := KimiCLIFallbackDisabledPath() | |||
| 199 | + | if !disabled { | |||
| 200 | + | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 201 | + | return err | |||
| 202 | + | } | |||
| 203 | + | return nil | |||
| 204 | + | } | |||
| 205 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 206 | + | return err | |||
| 207 | + | } | |||
| 208 | + | return os.WriteFile(path, []byte("disabled\n"), 0o600) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func loadKimiCodeCLIToken() *auth.OAuthToken { | |||
| 212 | + | home, err := os.UserHomeDir() | |||
| 213 | + | if err != nil { | |||
| 214 | + | return nil | |||
| 215 | + | } | |||
| 216 | + | b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json")) | |||
| 217 | + | if err != nil { | |||
| 218 | + | return nil | |||
| 219 | + | } | |||
| 220 | + | var raw struct { | |||
| 221 | + | AccessToken string `json:"access_token"` | |||
| 222 | + | RefreshToken string `json:"refresh_token"` | |||
| 223 | + | TokenType string `json:"token_type"` | |||
| 224 | + | ExpiresAt float64 `json:"expires_at"` | |||
| 225 | + | Scope string `json:"scope"` | |||
| 226 | + | ExpiresIn float64 `json:"expires_in"` | |||
| 227 | + | } | |||
| 228 | + | if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" { | |||
| 229 | + | return nil | |||
| 230 | + | } | |||
| 231 | + | sec := int64(raw.ExpiresAt) | |||
| 232 | + | nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9) | |||
| 233 | + | return &auth.OAuthToken{ | |||
| 234 | + | AccessToken: raw.AccessToken, | |||
| 235 | + | RefreshToken: raw.RefreshToken, | |||
| 236 | + | TokenType: raw.TokenType, | |||
| 237 | + | Scope: raw.Scope, | |||
| 238 | + | ClientID: auth.KimiOAuth.ClientID, | |||
| 239 | + | Expiry: time.Unix(sec, nsec), | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | ||||
| 164 | // loadOAuthToken reads the current OAuth token from auth.json for the | 243 | // loadOAuthToken reads the current OAuth token from auth.json for the | ||
| 165 | // given provider. Returns nil if no token is stored. | 244 | // given provider. Returns nil if no token is stored. | ||
| 166 | func loadOAuthToken(providerName string) *auth.OAuthToken { | 245 | func loadOAuthToken(providerName string) *auth.OAuthToken { | ||
| @@ -177,6 +256,14 @@ func loadOAuthToken(providerName string) *auth.OAuthToken { | |||||
| 177 | if c.OpenAI.OAuth != nil { | 256 | if c.OpenAI.OAuth != nil { | ||
| 178 | return c.OpenAI.OAuth | 257 | return c.OpenAI.OAuth | ||
| 179 | } | 258 | } | ||
| 259 | + | case "kimi": | |||
| 260 | + | if c.Kimi.OAuth != nil { | |||
| 261 | + | return c.Kimi.OAuth | |||
| 262 | + | } | |||
| 263 | + | if kimiCLIFallbackDisabled() { | |||
| 264 | + | return nil | |||
| 265 | + | } | |||
| 266 | + | return loadKimiCodeCLIToken() | |||
| 180 | } | 267 | } | ||
| 181 | return nil | 268 | return nil | ||
| 182 | } | 269 | } | ||
| @@ -205,6 +292,8 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok | |||||
| 205 | op = auth.AnthropicOAuth | 292 | op = auth.AnthropicOAuth | ||
| 206 | case "openai": | 293 | case "openai": | ||
| 207 | op = auth.OpenAIOAuth | 294 | op = auth.OpenAIOAuth | ||
| 295 | + | case "kimi": | |||
| 296 | + | op = auth.KimiOAuth | |||
| 208 | default: | 297 | default: | ||
| 209 | return tok, fmt.Errorf("unknown provider %q", providerName) | 298 | return tok, fmt.Errorf("unknown provider %q", providerName) | ||
| 210 | } | 299 | } | ||
M internal/agent/modelsync.go +9 -0
| @@ -72,6 +72,15 @@ func refreshModels() { | |||
| 72 | 72 | all = append(all, live...) | |
| 73 | 73 | } | |
| 74 | 74 | } | |
| 75 | + | if cred, method, err := ResolveCredential("kimi", ""); err == nil && method == "apikey" { | |
| 76 | + | if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil { | |
| 77 | + | for i := range live { | |
| 78 | + | live[i].Provider = "kimi" | |
| 79 | + | live[i].Source = "live" | |
| 80 | + | } | |
| 81 | + | all = append(all, live...) | |
| 82 | + | } | |
| 83 | + | } | |
| 75 | 84 | ||
| 76 | 85 | if len(all) == 0 { | |
| 77 | 86 | return | |
| @@ -72,6 +72,15 @@ func refreshModels() { | |||||
| 72 | all = append(all, live...) | 72 | all = append(all, live...) | ||
| 73 | } | 73 | } | ||
| 74 | } | 74 | } | ||
| 75 | + | if cred, method, err := ResolveCredential("kimi", ""); err == nil && method == "apikey" { | |||
| 76 | + | if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil { | |||
| 77 | + | for i := range live { | |||
| 78 | + | live[i].Provider = "kimi" | |||
| 79 | + | live[i].Source = "live" | |||
| 80 | + | } | |||
| 81 | + | all = append(all, live...) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 75 | 84 | ||||
| 76 | if len(all) == 0 { | 85 | if len(all) == 0 { | ||
| 77 | return | 86 | return | ||
M internal/agent/modes/interactive.go +21 -5
| @@ -54,6 +54,10 @@ type InteractiveConfig struct { | |||
| 54 | 54 | // the concrete provider/model in use. | |
| 55 | 55 | BuildAgent func() (*core.Agent, string, string, error) | |
| 56 | 56 | ||
| 57 | + | // SetKimiCLIFallbackDisabled controls whether zot may fall back to | |
| 58 | + | // the official Kimi Code CLI token when zot has no stored Kimi token. | |
| 59 | + | SetKimiCLIFallbackDisabled func(disabled bool) error | |
| 60 | + | ||
| 57 | 61 | // BuildAgentFor rebuilds the agent with an explicit provider/model | |
| 58 | 62 | // override (used by the /model picker when switching providers). | |
| 59 | 63 | // If providerOverride is empty, the current provider is kept. | |
| @@ -2397,7 +2401,7 @@ func (i *Interactive) openLogoutDialog() { | |||
| 2397 | 2401 | } | |
| 2398 | 2402 | ||
| 2399 | 2403 | var items []logoutItem | |
| 2400 | - | for _, p := range []string{"anthropic", "openai"} { | |
| 2404 | + | for _, p := range []string{"anthropic", "openai", "kimi"} { | |
| 2401 | 2405 | if creds.Has(p) { | |
| 2402 | 2406 | method := creds.Method(p) | |
| 2403 | 2407 | if method == "oauth" { | |
| @@ -2431,7 +2435,7 @@ func (i *Interactive) openLogoutDialog() { | |||
| 2431 | 2435 | // is torn down so the user is forced through /login before their next | |
| 2432 | 2436 | // prompt. | |
| 2433 | 2437 | // | |
| 2434 | - | // target: "anthropic" | "openai" | "all" | |
| 2438 | + | // target: "anthropic" | "openai" | "kimi" | "all" | |
| 2435 | 2439 | func (i *Interactive) doLogout(target string) { | |
| 2436 | 2440 | if i.cfg.AuthManager == nil { | |
| 2437 | 2441 | i.mu.Lock() | |
| @@ -2450,12 +2454,12 @@ func (i *Interactive) doLogout(target string) { | |||
| 2450 | 2454 | var providers []string | |
| 2451 | 2455 | switch target { | |
| 2452 | 2456 | case "", "all": | |
| 2453 | - | providers = []string{"anthropic", "openai"} | |
| 2454 | - | case "anthropic", "openai": | |
| 2457 | + | providers = []string{"anthropic", "openai", "kimi"} | |
| 2458 | + | case "anthropic", "openai", "kimi": | |
| 2455 | 2459 | providers = []string{target} | |
| 2456 | 2460 | default: | |
| 2457 | 2461 | i.mu.Lock() | |
| 2458 | - | i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)" | |
| 2462 | + | i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, or all)" | |
| 2459 | 2463 | i.mu.Unlock() | |
| 2460 | 2464 | return | |
| 2461 | 2465 | } | |
| @@ -2467,6 +2471,12 @@ func (i *Interactive) doLogout(target string) { | |||
| 2467 | 2471 | errs = append(errs, p+": "+err.Error()) | |
| 2468 | 2472 | continue | |
| 2469 | 2473 | } | |
| 2474 | + | if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 2475 | + | if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil { | |
| 2476 | + | errs = append(errs, p+": "+err.Error()) | |
| 2477 | + | continue | |
| 2478 | + | } | |
| 2479 | + | } | |
| 2470 | 2480 | if p == i.cfg.Provider { | |
| 2471 | 2481 | clearedCurrent = true | |
| 2472 | 2482 | } | |
| @@ -2491,6 +2501,9 @@ func (i *Interactive) doLogout(target string) { | |||
| 2491 | 2501 | } | |
| 2492 | 2502 | ||
| 2493 | 2503 | func (i *Interactive) startAPIKeyFlow(provider string) { | |
| 2504 | + | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 2505 | + | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | |
| 2506 | + | } | |
| 2494 | 2507 | url, err := i.cfg.AuthManager.StartAPIKey(provider) | |
| 2495 | 2508 | if err != nil { | |
| 2496 | 2509 | i.dialog.ShowResult(false, err.Error()) | |
| @@ -2500,6 +2513,9 @@ func (i *Interactive) startAPIKeyFlow(provider string) { | |||
| 2500 | 2513 | } | |
| 2501 | 2514 | ||
| 2502 | 2515 | func (i *Interactive) startOAuthFlow(provider string) { | |
| 2516 | + | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 2517 | + | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | |
| 2518 | + | } | |
| 2503 | 2519 | // Always run the manual/copy-code flow in parallel with the local | |
| 2504 | 2520 | // callback server so headless environments (docker, SSH) can paste | |
| 2505 | 2521 | // the authorization code directly without first pressing 'p'. | |
| @@ -54,6 +54,10 @@ type InteractiveConfig struct { | |||||
| 54 | // the concrete provider/model in use. | 54 | // the concrete provider/model in use. | ||
| 55 | BuildAgent func() (*core.Agent, string, string, error) | 55 | BuildAgent func() (*core.Agent, string, string, error) | ||
| 56 | 56 | ||||
| 57 | + | // SetKimiCLIFallbackDisabled controls whether zot may fall back to | |||
| 58 | + | // the official Kimi Code CLI token when zot has no stored Kimi token. | |||
| 59 | + | SetKimiCLIFallbackDisabled func(disabled bool) error | |||
| 60 | + | ||||
| 57 | // BuildAgentFor rebuilds the agent with an explicit provider/model | 61 | // BuildAgentFor rebuilds the agent with an explicit provider/model | ||
| 58 | // override (used by the /model picker when switching providers). | 62 | // override (used by the /model picker when switching providers). | ||
| 59 | // If providerOverride is empty, the current provider is kept. | 63 | // If providerOverride is empty, the current provider is kept. | ||
| @@ -2397,7 +2401,7 @@ func (i *Interactive) openLogoutDialog() { | |||||
| 2397 | } | 2401 | } | ||
| 2398 | 2402 | ||||
| 2399 | var items []logoutItem | 2403 | var items []logoutItem | ||
| 2400 | - | for _, p := range []string{"anthropic", "openai"} { | 2404 | + | for _, p := range []string{"anthropic", "openai", "kimi"} { |
| 2401 | if creds.Has(p) { | 2405 | if creds.Has(p) { | ||
| 2402 | method := creds.Method(p) | 2406 | method := creds.Method(p) | ||
| 2403 | if method == "oauth" { | 2407 | if method == "oauth" { | ||
| @@ -2431,7 +2435,7 @@ func (i *Interactive) openLogoutDialog() { | |||||
| 2431 | // is torn down so the user is forced through /login before their next | 2435 | // is torn down so the user is forced through /login before their next | ||
| 2432 | // prompt. | 2436 | // prompt. | ||
| 2433 | // | 2437 | // | ||
| 2434 | - | // target: "anthropic" | "openai" | "all" | 2438 | + | // target: "anthropic" | "openai" | "kimi" | "all" |
| 2435 | func (i *Interactive) doLogout(target string) { | 2439 | func (i *Interactive) doLogout(target string) { | ||
| 2436 | if i.cfg.AuthManager == nil { | 2440 | if i.cfg.AuthManager == nil { | ||
| 2437 | i.mu.Lock() | 2441 | i.mu.Lock() | ||
| @@ -2450,12 +2454,12 @@ func (i *Interactive) doLogout(target string) { | |||||
| 2450 | var providers []string | 2454 | var providers []string | ||
| 2451 | switch target { | 2455 | switch target { | ||
| 2452 | case "", "all": | 2456 | case "", "all": | ||
| 2453 | - | providers = []string{"anthropic", "openai"} | 2457 | + | providers = []string{"anthropic", "openai", "kimi"} |
| 2454 | - | case "anthropic", "openai": | 2458 | + | case "anthropic", "openai", "kimi": |
| 2455 | providers = []string{target} | 2459 | providers = []string{target} | ||
| 2456 | default: | 2460 | default: | ||
| 2457 | i.mu.Lock() | 2461 | i.mu.Lock() | ||
| 2458 | - | i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)" | 2462 | + | i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, or all)" |
| 2459 | i.mu.Unlock() | 2463 | i.mu.Unlock() | ||
| 2460 | return | 2464 | return | ||
| 2461 | } | 2465 | } | ||
| @@ -2467,6 +2471,12 @@ func (i *Interactive) doLogout(target string) { | |||||
| 2467 | errs = append(errs, p+": "+err.Error()) | 2471 | errs = append(errs, p+": "+err.Error()) | ||
| 2468 | continue | 2472 | continue | ||
| 2469 | } | 2473 | } | ||
| 2474 | + | if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |||
| 2475 | + | if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil { | |||
| 2476 | + | errs = append(errs, p+": "+err.Error()) | |||
| 2477 | + | continue | |||
| 2478 | + | } | |||
| 2479 | + | } | |||
| 2470 | if p == i.cfg.Provider { | 2480 | if p == i.cfg.Provider { | ||
| 2471 | clearedCurrent = true | 2481 | clearedCurrent = true | ||
| 2472 | } | 2482 | } | ||
| @@ -2491,6 +2501,9 @@ func (i *Interactive) doLogout(target string) { | |||||
| 2491 | } | 2501 | } | ||
| 2492 | 2502 | ||||
| 2493 | func (i *Interactive) startAPIKeyFlow(provider string) { | 2503 | func (i *Interactive) startAPIKeyFlow(provider string) { | ||
| 2504 | + | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |||
| 2505 | + | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | |||
| 2506 | + | } | |||
| 2494 | url, err := i.cfg.AuthManager.StartAPIKey(provider) | 2507 | url, err := i.cfg.AuthManager.StartAPIKey(provider) | ||
| 2495 | if err != nil { | 2508 | if err != nil { | ||
| 2496 | i.dialog.ShowResult(false, err.Error()) | 2509 | i.dialog.ShowResult(false, err.Error()) | ||
| @@ -2500,6 +2513,9 @@ func (i *Interactive) startAPIKeyFlow(provider string) { | |||||
| 2500 | } | 2513 | } | ||
| 2501 | 2514 | ||||
| 2502 | func (i *Interactive) startOAuthFlow(provider string) { | 2515 | func (i *Interactive) startOAuthFlow(provider string) { | ||
| 2516 | + | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |||
| 2517 | + | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | |||
| 2518 | + | } | |||
| 2503 | // Always run the manual/copy-code flow in parallel with the local | 2519 | // Always run the manual/copy-code flow in parallel with the local | ||
| 2504 | // callback server so headless environments (docker, SSH) can paste | 2520 | // callback server so headless environments (docker, SSH) can paste | ||
| 2505 | // the authorization code directly without first pressing 'p'. | 2521 | // the authorization code directly without first pressing 'p'. | ||
M internal/agent/modes/login_dialog.go +14 -9
| @@ -17,7 +17,7 @@ type loginStep int | |||
| 17 | 17 | const ( | |
| 18 | 18 | loginStepClosed loginStep = iota | |
| 19 | 19 | loginStepMethod // pick apikey vs subscription | |
| 20 | - | loginStepProvider // pick anthropic vs openai | |
| 20 | + | loginStepProvider // pick anthropic vs openai vs kimi | |
| 21 | 21 | loginStepWaiting // browser open, waiting for callback | |
| 22 | 22 | loginStepPasteCode // user pastes the auth code here | |
| 23 | 23 | loginStepDone // success or error, waiting for key to dismiss | |
| @@ -28,7 +28,7 @@ const ( | |||
| 28 | 28 | type loginDialog struct { | |
| 29 | 29 | step loginStep | |
| 30 | 30 | method string // "apikey" | "oauth" | |
| 31 | - | provider string // "anthropic" | "openai" | |
| 31 | + | provider string // "anthropic" | "openai" | "kimi" | |
| 32 | 32 | message string | |
| 33 | 33 | success bool | |
| 34 | 34 | url string | |
| @@ -39,7 +39,7 @@ type loginDialog struct { | |||
| 39 | 39 | // provider, captured when Open() runs. Rendered above the | |
| 40 | 40 | // method picker so the user can see whether they're already | |
| 41 | 41 | // logged in (and how) before starting a new flow. Keys: | |
| 42 | - | // "anthropic", "openai". Value is "apikey", "oauth", or "" | |
| 42 | + | // "anthropic", "openai", "kimi". Value is "apikey", "oauth", or "" | |
| 43 | 43 | // (not logged in). | |
| 44 | 44 | status map[string]string | |
| 45 | 45 | } | |
| @@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) { | |||
| 65 | 65 | d.success = false | |
| 66 | 66 | d.url = "" | |
| 67 | 67 | d.cursor = 0 | |
| 68 | - | d.status = map[string]string{"anthropic": "", "openai": ""} | |
| 68 | + | d.status = map[string]string{"anthropic": "", "openai": "", "kimi": ""} | |
| 69 | 69 | // Best-effort: if the auth file can't be read, treat every | |
| 70 | 70 | // provider as not-logged-in. The status line just won't show | |
| 71 | 71 | // anything useful in that case, which is fine — the user | |
| @@ -74,6 +74,7 @@ func (d *loginDialog) Open(zotHome string) { | |||
| 74 | 74 | if creds, err := auth.NewStore(path).Load(); err == nil { | |
| 75 | 75 | d.status["anthropic"] = creds.Method("anthropic") | |
| 76 | 76 | d.status["openai"] = creds.Method("openai") | |
| 77 | + | d.status["kimi"] = creds.Method("kimi") | |
| 77 | 78 | } | |
| 78 | 79 | } | |
| 79 | 80 | ||
| @@ -93,7 +94,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 93 | 94 | case loginStepMethod: | |
| 94 | 95 | opts := []string{ | |
| 95 | 96 | "api key", | |
| 96 | - | "subscription (claude pro/max - chatgpt plus/pro)", | |
| 97 | + | "subscription (claude pro/max - chatgpt plus/pro - kimi code)", | |
| 97 | 98 | } | |
| 98 | 99 | lines = append(lines, frameHeader(th, "login", width)) | |
| 99 | 100 | for _, l := range d.renderStatusLines(th) { | |
| @@ -110,7 +111,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 110 | 111 | } | |
| 111 | 112 | lines = append(lines, frameRule(th, width)) | |
| 112 | 113 | case loginStepProvider: | |
| 113 | - | opts := []string{"anthropic", "openai"} | |
| 114 | + | opts := []string{"anthropic", "openai", "kimi"} | |
| 114 | 115 | lines = append(lines, frameHeader(th, "login - "+d.method, width)) | |
| 115 | 116 | for _, l := range d.renderStatusLines(th) { | |
| 116 | 117 | lines = append(lines, l) | |
| @@ -201,6 +202,8 @@ func providerLabel(id string) string { | |||
| 201 | 202 | return "Anthropic (Claude Pro/Max)" | |
| 202 | 203 | case "openai": | |
| 203 | 204 | return "OpenAI (ChatGPT Plus/Pro)" | |
| 205 | + | case "kimi": | |
| 206 | + | return "Kimi Code" | |
| 204 | 207 | } | |
| 205 | 208 | return id | |
| 206 | 209 | } | |
| @@ -218,7 +221,8 @@ func providerLabel(id string) string { | |||
| 218 | 221 | func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |
| 219 | 222 | anth := d.status["anthropic"] | |
| 220 | 223 | op := d.status["openai"] | |
| 221 | - | if anth == "" && op == "" { | |
| 224 | + | kimi := d.status["kimi"] | |
| 225 | + | if anth == "" && op == "" && kimi == "" { | |
| 222 | 226 | return nil | |
| 223 | 227 | } | |
| 224 | 228 | row := func(id, method string) string { | |
| @@ -240,6 +244,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||
| 240 | 244 | return []string{ | |
| 241 | 245 | row("anthropic", anth), | |
| 242 | 246 | row("openai", op), | |
| 247 | + | row("kimi", kimi), | |
| 243 | 248 | "", | |
| 244 | 249 | } | |
| 245 | 250 | } | |
| @@ -305,14 +310,14 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 305 | 310 | d.cursor-- | |
| 306 | 311 | } | |
| 307 | 312 | case tui.KeyDown: | |
| 308 | - | if d.cursor < 1 { | |
| 313 | + | if d.cursor < 2 { | |
| 309 | 314 | d.cursor++ | |
| 310 | 315 | } | |
| 311 | 316 | case tui.KeyEsc: | |
| 312 | 317 | d.Close() | |
| 313 | 318 | return loginDialogAction{Close: true} | |
| 314 | 319 | case tui.KeyEnter: | |
| 315 | - | providers := []string{"anthropic", "openai"} | |
| 320 | + | providers := []string{"anthropic", "openai", "kimi"} | |
| 316 | 321 | d.provider = providers[d.cursor] | |
| 317 | 322 | d.step = loginStepWaiting | |
| 318 | 323 | if d.method == "apikey" { | |
| @@ -17,7 +17,7 @@ type loginStep int | |||||
| 17 | const ( | 17 | const ( | ||
| 18 | loginStepClosed loginStep = iota | 18 | loginStepClosed loginStep = iota | ||
| 19 | loginStepMethod // pick apikey vs subscription | 19 | loginStepMethod // pick apikey vs subscription | ||
| 20 | - | loginStepProvider // pick anthropic vs openai | 20 | + | loginStepProvider // pick anthropic vs openai vs kimi |
| 21 | loginStepWaiting // browser open, waiting for callback | 21 | loginStepWaiting // browser open, waiting for callback | ||
| 22 | loginStepPasteCode // user pastes the auth code here | 22 | loginStepPasteCode // user pastes the auth code here | ||
| 23 | loginStepDone // success or error, waiting for key to dismiss | 23 | loginStepDone // success or error, waiting for key to dismiss | ||
| @@ -28,7 +28,7 @@ const ( | |||||
| 28 | type loginDialog struct { | 28 | type loginDialog struct { | ||
| 29 | step loginStep | 29 | step loginStep | ||
| 30 | method string // "apikey" | "oauth" | 30 | method string // "apikey" | "oauth" | ||
| 31 | - | provider string // "anthropic" | "openai" | 31 | + | provider string // "anthropic" | "openai" | "kimi" |
| 32 | message string | 32 | message string | ||
| 33 | success bool | 33 | success bool | ||
| 34 | url string | 34 | url string | ||
| @@ -39,7 +39,7 @@ type loginDialog struct { | |||||
| 39 | // provider, captured when Open() runs. Rendered above the | 39 | // provider, captured when Open() runs. Rendered above the | ||
| 40 | // method picker so the user can see whether they're already | 40 | // method picker so the user can see whether they're already | ||
| 41 | // logged in (and how) before starting a new flow. Keys: | 41 | // logged in (and how) before starting a new flow. Keys: | ||
| 42 | - | // "anthropic", "openai". Value is "apikey", "oauth", or "" | 42 | + | // "anthropic", "openai", "kimi". Value is "apikey", "oauth", or "" |
| 43 | // (not logged in). | 43 | // (not logged in). | ||
| 44 | status map[string]string | 44 | status map[string]string | ||
| 45 | } | 45 | } | ||
| @@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) { | |||||
| 65 | d.success = false | 65 | d.success = false | ||
| 66 | d.url = "" | 66 | d.url = "" | ||
| 67 | d.cursor = 0 | 67 | d.cursor = 0 | ||
| 68 | - | d.status = map[string]string{"anthropic": "", "openai": ""} | 68 | + | d.status = map[string]string{"anthropic": "", "openai": "", "kimi": ""} |
| 69 | // Best-effort: if the auth file can't be read, treat every | 69 | // Best-effort: if the auth file can't be read, treat every | ||
| 70 | // provider as not-logged-in. The status line just won't show | 70 | // provider as not-logged-in. The status line just won't show | ||
| 71 | // anything useful in that case, which is fine — the user | 71 | // anything useful in that case, which is fine — the user | ||
| @@ -74,6 +74,7 @@ func (d *loginDialog) Open(zotHome string) { | |||||
| 74 | if creds, err := auth.NewStore(path).Load(); err == nil { | 74 | if creds, err := auth.NewStore(path).Load(); err == nil { | ||
| 75 | d.status["anthropic"] = creds.Method("anthropic") | 75 | d.status["anthropic"] = creds.Method("anthropic") | ||
| 76 | d.status["openai"] = creds.Method("openai") | 76 | d.status["openai"] = creds.Method("openai") | ||
| 77 | + | d.status["kimi"] = creds.Method("kimi") | |||
| 77 | } | 78 | } | ||
| 78 | } | 79 | } | ||
| 79 | 80 | ||||
| @@ -93,7 +94,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 93 | case loginStepMethod: | 94 | case loginStepMethod: | ||
| 94 | opts := []string{ | 95 | opts := []string{ | ||
| 95 | "api key", | 96 | "api key", | ||
| 96 | - | "subscription (claude pro/max - chatgpt plus/pro)", | 97 | + | "subscription (claude pro/max - chatgpt plus/pro - kimi code)", |
| 97 | } | 98 | } | ||
| 98 | lines = append(lines, frameHeader(th, "login", width)) | 99 | lines = append(lines, frameHeader(th, "login", width)) | ||
| 99 | for _, l := range d.renderStatusLines(th) { | 100 | for _, l := range d.renderStatusLines(th) { | ||
| @@ -110,7 +111,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 110 | } | 111 | } | ||
| 111 | lines = append(lines, frameRule(th, width)) | 112 | lines = append(lines, frameRule(th, width)) | ||
| 112 | case loginStepProvider: | 113 | case loginStepProvider: | ||
| 113 | - | opts := []string{"anthropic", "openai"} | 114 | + | opts := []string{"anthropic", "openai", "kimi"} |
| 114 | lines = append(lines, frameHeader(th, "login - "+d.method, width)) | 115 | lines = append(lines, frameHeader(th, "login - "+d.method, width)) | ||
| 115 | for _, l := range d.renderStatusLines(th) { | 116 | for _, l := range d.renderStatusLines(th) { | ||
| 116 | lines = append(lines, l) | 117 | lines = append(lines, l) | ||
| @@ -201,6 +202,8 @@ func providerLabel(id string) string { | |||||
| 201 | return "Anthropic (Claude Pro/Max)" | 202 | return "Anthropic (Claude Pro/Max)" | ||
| 202 | case "openai": | 203 | case "openai": | ||
| 203 | return "OpenAI (ChatGPT Plus/Pro)" | 204 | return "OpenAI (ChatGPT Plus/Pro)" | ||
| 205 | + | case "kimi": | |||
| 206 | + | return "Kimi Code" | |||
| 204 | } | 207 | } | ||
| 205 | return id | 208 | return id | ||
| 206 | } | 209 | } | ||
| @@ -218,7 +221,8 @@ func providerLabel(id string) string { | |||||
| 218 | func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | 221 | func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | ||
| 219 | anth := d.status["anthropic"] | 222 | anth := d.status["anthropic"] | ||
| 220 | op := d.status["openai"] | 223 | op := d.status["openai"] | ||
| 221 | - | if anth == "" && op == "" { | 224 | + | kimi := d.status["kimi"] |
| 225 | + | if anth == "" && op == "" && kimi == "" { | |||
| 222 | return nil | 226 | return nil | ||
| 223 | } | 227 | } | ||
| 224 | row := func(id, method string) string { | 228 | row := func(id, method string) string { | ||
| @@ -240,6 +244,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||||
| 240 | return []string{ | 244 | return []string{ | ||
| 241 | row("anthropic", anth), | 245 | row("anthropic", anth), | ||
| 242 | row("openai", op), | 246 | row("openai", op), | ||
| 247 | + | row("kimi", kimi), | |||
| 243 | "", | 248 | "", | ||
| 244 | } | 249 | } | ||
| 245 | } | 250 | } | ||
| @@ -305,14 +310,14 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 305 | d.cursor-- | 310 | d.cursor-- | ||
| 306 | } | 311 | } | ||
| 307 | case tui.KeyDown: | 312 | case tui.KeyDown: | ||
| 308 | - | if d.cursor < 1 { | 313 | + | if d.cursor < 2 { |
| 309 | d.cursor++ | 314 | d.cursor++ | ||
| 310 | } | 315 | } | ||
| 311 | case tui.KeyEsc: | 316 | case tui.KeyEsc: | ||
| 312 | d.Close() | 317 | d.Close() | ||
| 313 | return loginDialogAction{Close: true} | 318 | return loginDialogAction{Close: true} | ||
| 314 | case tui.KeyEnter: | 319 | case tui.KeyEnter: | ||
| 315 | - | providers := []string{"anthropic", "openai"} | 320 | + | providers := []string{"anthropic", "openai", "kimi"} |
| 316 | d.provider = providers[d.cursor] | 321 | d.provider = providers[d.cursor] | ||
| 317 | d.step = loginStepWaiting | 322 | d.step = loginStepWaiting | ||
| 318 | if d.method == "apikey" { | 323 | if d.method == "apikey" { | ||
A internal/auth/kimi.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | package auth | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "net/http" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // KimiDeviceAuthorization is the OAuth 2 device-code response used by | |
| 14 | + | // the official Kimi Code CLI. | |
| 15 | + | type KimiDeviceAuthorization struct { | |
| 16 | + | UserCode string `json:"user_code"` | |
| 17 | + | DeviceCode string `json:"device_code"` | |
| 18 | + | VerificationURI string `json:"verification_uri"` | |
| 19 | + | VerificationURIComplete string `json:"verification_uri_complete"` | |
| 20 | + | ExpiresIn int `json:"expires_in"` | |
| 21 | + | Interval int `json:"interval"` | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // RequestKimiDeviceAuthorization starts Kimi Code's device-code login. | |
| 25 | + | func RequestKimiDeviceAuthorization(ctx context.Context) (KimiDeviceAuthorization, error) { | |
| 26 | + | form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID) | |
| 27 | + | req, err := http.NewRequestWithContext(ctx, "POST", "https://auth.kimi.com/api/oauth/device_authorization", form) | |
| 28 | + | if err != nil { | |
| 29 | + | return KimiDeviceAuthorization{}, err | |
| 30 | + | } | |
| 31 | + | req.Header.Set("content-type", "application/x-www-form-urlencoded") | |
| 32 | + | req.Header.Set("accept", "application/json") | |
| 33 | + | req.Header.Set("user-agent", "zot") | |
| 34 | + | ||
| 35 | + | resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) | |
| 36 | + | if err != nil { | |
| 37 | + | return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization: %w", err) | |
| 38 | + | } | |
| 39 | + | defer resp.Body.Close() | |
| 40 | + | body, _ := io.ReadAll(resp.Body) | |
| 41 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |
| 42 | + | return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization http %d: %s", resp.StatusCode, string(body)) | |
| 43 | + | } | |
| 44 | + | var out KimiDeviceAuthorization | |
| 45 | + | if err := json.Unmarshal(body, &out); err != nil { | |
| 46 | + | return out, fmt.Errorf("parse kimi device authorization: %w", err) | |
| 47 | + | } | |
| 48 | + | if out.Interval <= 0 { | |
| 49 | + | out.Interval = 5 | |
| 50 | + | } | |
| 51 | + | return out, nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // PollKimiDeviceToken polls until the browser/device-code login completes. | |
| 55 | + | func PollKimiDeviceToken(ctx context.Context, auth KimiDeviceAuthorization) (*OAuthToken, error) { | |
| 56 | + | interval := time.Duration(auth.Interval) * time.Second | |
| 57 | + | if interval <= 0 { | |
| 58 | + | interval = 5 * time.Second | |
| 59 | + | } | |
| 60 | + | for { | |
| 61 | + | tok, retry, err := pollKimiDeviceTokenOnce(ctx, auth.DeviceCode) | |
| 62 | + | if err != nil { | |
| 63 | + | return nil, err | |
| 64 | + | } | |
| 65 | + | if tok != nil { | |
| 66 | + | return tok, nil | |
| 67 | + | } | |
| 68 | + | select { | |
| 69 | + | case <-ctx.Done(): | |
| 70 | + | return nil, ctx.Err() | |
| 71 | + | case <-time.After(retry): | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func pollKimiDeviceTokenOnce(ctx context.Context, deviceCode string) (*OAuthToken, time.Duration, error) { | |
| 77 | + | form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID + "&device_code=" + deviceCode + "&grant_type=urn:ietf:params:oauth:grant-type:device_code") | |
| 78 | + | req, err := http.NewRequestWithContext(ctx, "POST", KimiOAuth.TokenURL, form) | |
| 79 | + | if err != nil { | |
| 80 | + | return nil, 0, err | |
| 81 | + | } | |
| 82 | + | req.Header.Set("content-type", "application/x-www-form-urlencoded") | |
| 83 | + | req.Header.Set("accept", "application/json") | |
| 84 | + | req.Header.Set("user-agent", "zot") | |
| 85 | + | resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) | |
| 86 | + | if err != nil { | |
| 87 | + | return nil, 0, fmt.Errorf("kimi token poll: %w", err) | |
| 88 | + | } | |
| 89 | + | defer resp.Body.Close() | |
| 90 | + | body, _ := io.ReadAll(resp.Body) | |
| 91 | + | var raw struct { | |
| 92 | + | AccessToken string `json:"access_token"` | |
| 93 | + | RefreshToken string `json:"refresh_token"` | |
| 94 | + | TokenType string `json:"token_type"` | |
| 95 | + | ExpiresIn float64 `json:"expires_in"` | |
| 96 | + | Scope string `json:"scope"` | |
| 97 | + | Error string `json:"error"` | |
| 98 | + | ErrorDescription string `json:"error_description"` | |
| 99 | + | } | |
| 100 | + | _ = json.Unmarshal(body, &raw) | |
| 101 | + | if resp.StatusCode == http.StatusOK && raw.AccessToken != "" { | |
| 102 | + | return &OAuthToken{ | |
| 103 | + | AccessToken: raw.AccessToken, | |
| 104 | + | RefreshToken: raw.RefreshToken, | |
| 105 | + | TokenType: raw.TokenType, | |
| 106 | + | Scope: raw.Scope, | |
| 107 | + | ClientID: KimiOAuth.ClientID, | |
| 108 | + | Expiry: time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second), | |
| 109 | + | }, 0, nil | |
| 110 | + | } | |
| 111 | + | if raw.Error == "authorization_pending" || raw.Error == "slow_down" || resp.StatusCode == http.StatusBadRequest { | |
| 112 | + | return nil, 5 * time.Second, nil | |
| 113 | + | } | |
| 114 | + | if raw.Error != "" { | |
| 115 | + | return nil, 0, fmt.Errorf("kimi token poll: %s: %s", raw.Error, raw.ErrorDescription) | |
| 116 | + | } | |
| 117 | + | return nil, 0, fmt.Errorf("kimi token poll http %d: %s", resp.StatusCode, string(body)) | |
| 118 | + | } | |
| @@ -0,0 +1,118 @@ | |||||
| 1 | + | package auth | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "net/http" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // KimiDeviceAuthorization is the OAuth 2 device-code response used by | |||
| 14 | + | // the official Kimi Code CLI. | |||
| 15 | + | type KimiDeviceAuthorization struct { | |||
| 16 | + | UserCode string `json:"user_code"` | |||
| 17 | + | DeviceCode string `json:"device_code"` | |||
| 18 | + | VerificationURI string `json:"verification_uri"` | |||
| 19 | + | VerificationURIComplete string `json:"verification_uri_complete"` | |||
| 20 | + | ExpiresIn int `json:"expires_in"` | |||
| 21 | + | Interval int `json:"interval"` | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // RequestKimiDeviceAuthorization starts Kimi Code's device-code login. | |||
| 25 | + | func RequestKimiDeviceAuthorization(ctx context.Context) (KimiDeviceAuthorization, error) { | |||
| 26 | + | form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID) | |||
| 27 | + | req, err := http.NewRequestWithContext(ctx, "POST", "https://auth.kimi.com/api/oauth/device_authorization", form) | |||
| 28 | + | if err != nil { | |||
| 29 | + | return KimiDeviceAuthorization{}, err | |||
| 30 | + | } | |||
| 31 | + | req.Header.Set("content-type", "application/x-www-form-urlencoded") | |||
| 32 | + | req.Header.Set("accept", "application/json") | |||
| 33 | + | req.Header.Set("user-agent", "zot") | |||
| 34 | + | ||||
| 35 | + | resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization: %w", err) | |||
| 38 | + | } | |||
| 39 | + | defer resp.Body.Close() | |||
| 40 | + | body, _ := io.ReadAll(resp.Body) | |||
| 41 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |||
| 42 | + | return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization http %d: %s", resp.StatusCode, string(body)) | |||
| 43 | + | } | |||
| 44 | + | var out KimiDeviceAuthorization | |||
| 45 | + | if err := json.Unmarshal(body, &out); err != nil { | |||
| 46 | + | return out, fmt.Errorf("parse kimi device authorization: %w", err) | |||
| 47 | + | } | |||
| 48 | + | if out.Interval <= 0 { | |||
| 49 | + | out.Interval = 5 | |||
| 50 | + | } | |||
| 51 | + | return out, nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // PollKimiDeviceToken polls until the browser/device-code login completes. | |||
| 55 | + | func PollKimiDeviceToken(ctx context.Context, auth KimiDeviceAuthorization) (*OAuthToken, error) { | |||
| 56 | + | interval := time.Duration(auth.Interval) * time.Second | |||
| 57 | + | if interval <= 0 { | |||
| 58 | + | interval = 5 * time.Second | |||
| 59 | + | } | |||
| 60 | + | for { | |||
| 61 | + | tok, retry, err := pollKimiDeviceTokenOnce(ctx, auth.DeviceCode) | |||
| 62 | + | if err != nil { | |||
| 63 | + | return nil, err | |||
| 64 | + | } | |||
| 65 | + | if tok != nil { | |||
| 66 | + | return tok, nil | |||
| 67 | + | } | |||
| 68 | + | select { | |||
| 69 | + | case <-ctx.Done(): | |||
| 70 | + | return nil, ctx.Err() | |||
| 71 | + | case <-time.After(retry): | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func pollKimiDeviceTokenOnce(ctx context.Context, deviceCode string) (*OAuthToken, time.Duration, error) { | |||
| 77 | + | form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID + "&device_code=" + deviceCode + "&grant_type=urn:ietf:params:oauth:grant-type:device_code") | |||
| 78 | + | req, err := http.NewRequestWithContext(ctx, "POST", KimiOAuth.TokenURL, form) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return nil, 0, err | |||
| 81 | + | } | |||
| 82 | + | req.Header.Set("content-type", "application/x-www-form-urlencoded") | |||
| 83 | + | req.Header.Set("accept", "application/json") | |||
| 84 | + | req.Header.Set("user-agent", "zot") | |||
| 85 | + | resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) | |||
| 86 | + | if err != nil { | |||
| 87 | + | return nil, 0, fmt.Errorf("kimi token poll: %w", err) | |||
| 88 | + | } | |||
| 89 | + | defer resp.Body.Close() | |||
| 90 | + | body, _ := io.ReadAll(resp.Body) | |||
| 91 | + | var raw struct { | |||
| 92 | + | AccessToken string `json:"access_token"` | |||
| 93 | + | RefreshToken string `json:"refresh_token"` | |||
| 94 | + | TokenType string `json:"token_type"` | |||
| 95 | + | ExpiresIn float64 `json:"expires_in"` | |||
| 96 | + | Scope string `json:"scope"` | |||
| 97 | + | Error string `json:"error"` | |||
| 98 | + | ErrorDescription string `json:"error_description"` | |||
| 99 | + | } | |||
| 100 | + | _ = json.Unmarshal(body, &raw) | |||
| 101 | + | if resp.StatusCode == http.StatusOK && raw.AccessToken != "" { | |||
| 102 | + | return &OAuthToken{ | |||
| 103 | + | AccessToken: raw.AccessToken, | |||
| 104 | + | RefreshToken: raw.RefreshToken, | |||
| 105 | + | TokenType: raw.TokenType, | |||
| 106 | + | Scope: raw.Scope, | |||
| 107 | + | ClientID: KimiOAuth.ClientID, | |||
| 108 | + | Expiry: time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second), | |||
| 109 | + | }, 0, nil | |||
| 110 | + | } | |||
| 111 | + | if raw.Error == "authorization_pending" || raw.Error == "slow_down" || resp.StatusCode == http.StatusBadRequest { | |||
| 112 | + | return nil, 5 * time.Second, nil | |||
| 113 | + | } | |||
| 114 | + | if raw.Error != "" { | |||
| 115 | + | return nil, 0, fmt.Errorf("kimi token poll: %s: %s", raw.Error, raw.ErrorDescription) | |||
| 116 | + | } | |||
| 117 | + | return nil, 0, fmt.Errorf("kimi token poll http %d: %s", resp.StatusCode, string(body)) | |||
| 118 | + | } | |||
M internal/auth/manager.go +45 -4
| @@ -78,8 +78,8 @@ func (m *Manager) Close() { | |||
| 78 | 78 | ||
| 79 | 79 | // StartAPIKey launches the API-key login flow. | |
| 80 | 80 | func (m *Manager) StartAPIKey(provider string) (string, error) { | |
| 81 | - | if provider != "anthropic" && provider != "openai" { | |
| 82 | - | return "", fmt.Errorf("provider must be anthropic or openai") | |
| 81 | + | if provider != "anthropic" && provider != "openai" && provider != "kimi" { | |
| 82 | + | return "", fmt.Errorf("provider must be anthropic, openai, or kimi") | |
| 83 | 83 | } | |
| 84 | 84 | if err := m.ensureKeyServer(); err != nil { | |
| 85 | 85 | return "", err | |
| @@ -125,6 +125,9 @@ func (m *Manager) consumeKeyServerResults() { | |||
| 125 | 125 | // Only one oauth flow may be in progress at a time (because the | |
| 126 | 126 | // callback port is fixed per provider and re-used by the official CLIs). | |
| 127 | 127 | func (m *Manager) StartOAuth(provider string) (string, error) { | |
| 128 | + | if provider == "kimi" { | |
| 129 | + | return m.StartKimiDeviceOAuth() | |
| 130 | + | } | |
| 128 | 131 | var op OAuthProvider | |
| 129 | 132 | switch provider { | |
| 130 | 133 | case "anthropic": | |
| @@ -132,7 +135,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 132 | 135 | case "openai": | |
| 133 | 136 | op = OpenAIOAuth | |
| 134 | 137 | default: | |
| 135 | - | return "", fmt.Errorf("provider must be anthropic or openai") | |
| 138 | + | return "", fmt.Errorf("provider must be anthropic, openai, or kimi") | |
| 136 | 139 | } | |
| 137 | 140 | ||
| 138 | 141 | m.mu.Lock() | |
| @@ -203,11 +206,49 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback | |||
| 203 | 206 | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | |
| 204 | 207 | } | |
| 205 | 208 | ||
| 209 | + | // StartKimiDeviceOAuth starts Kimi Code's device-code subscription login. | |
| 210 | + | func (m *Manager) StartKimiDeviceOAuth() (string, error) { | |
| 211 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 212 | + | m.mu.Lock() | |
| 213 | + | if m.oauthCancel != nil { | |
| 214 | + | m.oauthCancel() | |
| 215 | + | } | |
| 216 | + | m.oauthCtx = ctx | |
| 217 | + | m.oauthCancel = cancel | |
| 218 | + | m.mu.Unlock() | |
| 219 | + | ||
| 220 | + | dev, err := RequestKimiDeviceAuthorization(ctx) | |
| 221 | + | if err != nil { | |
| 222 | + | return "", err | |
| 223 | + | } | |
| 224 | + | url := dev.VerificationURIComplete | |
| 225 | + | go m.maybeOpen(url) | |
| 226 | + | m.emit(Event{Kind: "started", Provider: "kimi", Method: "oauth", URL: url}) | |
| 227 | + | go func() { | |
| 228 | + | tok, err := PollKimiDeviceToken(ctx, dev) | |
| 229 | + | if err != nil { | |
| 230 | + | if ctx.Err() == nil { | |
| 231 | + | m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()}) | |
| 232 | + | } | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | if err := m.store.SetOAuth("kimi", *tok); err != nil { | |
| 236 | + | m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()}) | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | m.emit(Event{Kind: "success", Provider: "kimi", Method: "oauth"}) | |
| 240 | + | }() | |
| 241 | + | return url, nil | |
| 242 | + | } | |
| 243 | + | ||
| 206 | 244 | // StartManualOAuth begins an OAuth flow but does NOT start a local | |
| 207 | 245 | // callback server or open a browser. The returned URL is shown to the | |
| 208 | 246 | // user so they can complete the authorization on another device; the | |
| 209 | 247 | // resulting code is pasted back via CompleteManualOAuth. | |
| 210 | 248 | func (m *Manager) StartManualOAuth(provider string) (string, error) { | |
| 249 | + | if provider == "kimi" { | |
| 250 | + | return m.StartKimiDeviceOAuth() | |
| 251 | + | } | |
| 211 | 252 | var op OAuthProvider | |
| 212 | 253 | switch provider { | |
| 213 | 254 | case "anthropic": | |
| @@ -215,7 +256,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 215 | 256 | case "openai": | |
| 216 | 257 | op = OpenAIOAuth | |
| 217 | 258 | default: | |
| 218 | - | return "", fmt.Errorf("provider must be anthropic or openai") | |
| 259 | + | return "", fmt.Errorf("provider must be anthropic, openai, or kimi") | |
| 219 | 260 | } | |
| 220 | 261 | ||
| 221 | 262 | pkce, err := NewPKCE() | |
| @@ -78,8 +78,8 @@ func (m *Manager) Close() { | |||||
| 78 | 78 | ||||
| 79 | // StartAPIKey launches the API-key login flow. | 79 | // StartAPIKey launches the API-key login flow. | ||
| 80 | func (m *Manager) StartAPIKey(provider string) (string, error) { | 80 | func (m *Manager) StartAPIKey(provider string) (string, error) { | ||
| 81 | - | if provider != "anthropic" && provider != "openai" { | 81 | + | if provider != "anthropic" && provider != "openai" && provider != "kimi" { |
| 82 | - | return "", fmt.Errorf("provider must be anthropic or openai") | 82 | + | return "", fmt.Errorf("provider must be anthropic, openai, or kimi") |
| 83 | } | 83 | } | ||
| 84 | if err := m.ensureKeyServer(); err != nil { | 84 | if err := m.ensureKeyServer(); err != nil { | ||
| 85 | return "", err | 85 | return "", err | ||
| @@ -125,6 +125,9 @@ func (m *Manager) consumeKeyServerResults() { | |||||
| 125 | // Only one oauth flow may be in progress at a time (because the | 125 | // Only one oauth flow may be in progress at a time (because the | ||
| 126 | // callback port is fixed per provider and re-used by the official CLIs). | 126 | // callback port is fixed per provider and re-used by the official CLIs). | ||
| 127 | func (m *Manager) StartOAuth(provider string) (string, error) { | 127 | func (m *Manager) StartOAuth(provider string) (string, error) { | ||
| 128 | + | if provider == "kimi" { | |||
| 129 | + | return m.StartKimiDeviceOAuth() | |||
| 130 | + | } | |||
| 128 | var op OAuthProvider | 131 | var op OAuthProvider | ||
| 129 | switch provider { | 132 | switch provider { | ||
| 130 | case "anthropic": | 133 | case "anthropic": | ||
| @@ -132,7 +135,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||||
| 132 | case "openai": | 135 | case "openai": | ||
| 133 | op = OpenAIOAuth | 136 | op = OpenAIOAuth | ||
| 134 | default: | 137 | default: | ||
| 135 | - | return "", fmt.Errorf("provider must be anthropic or openai") | 138 | + | return "", fmt.Errorf("provider must be anthropic, openai, or kimi") |
| 136 | } | 139 | } | ||
| 137 | 140 | ||||
| 138 | m.mu.Lock() | 141 | m.mu.Lock() | ||
| @@ -203,11 +206,49 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback | |||||
| 203 | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | 206 | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | ||
| 204 | } | 207 | } | ||
| 205 | 208 | ||||
| 209 | + | // StartKimiDeviceOAuth starts Kimi Code's device-code subscription login. | |||
| 210 | + | func (m *Manager) StartKimiDeviceOAuth() (string, error) { | |||
| 211 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 212 | + | m.mu.Lock() | |||
| 213 | + | if m.oauthCancel != nil { | |||
| 214 | + | m.oauthCancel() | |||
| 215 | + | } | |||
| 216 | + | m.oauthCtx = ctx | |||
| 217 | + | m.oauthCancel = cancel | |||
| 218 | + | m.mu.Unlock() | |||
| 219 | + | ||||
| 220 | + | dev, err := RequestKimiDeviceAuthorization(ctx) | |||
| 221 | + | if err != nil { | |||
| 222 | + | return "", err | |||
| 223 | + | } | |||
| 224 | + | url := dev.VerificationURIComplete | |||
| 225 | + | go m.maybeOpen(url) | |||
| 226 | + | m.emit(Event{Kind: "started", Provider: "kimi", Method: "oauth", URL: url}) | |||
| 227 | + | go func() { | |||
| 228 | + | tok, err := PollKimiDeviceToken(ctx, dev) | |||
| 229 | + | if err != nil { | |||
| 230 | + | if ctx.Err() == nil { | |||
| 231 | + | m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()}) | |||
| 232 | + | } | |||
| 233 | + | return | |||
| 234 | + | } | |||
| 235 | + | if err := m.store.SetOAuth("kimi", *tok); err != nil { | |||
| 236 | + | m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()}) | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | m.emit(Event{Kind: "success", Provider: "kimi", Method: "oauth"}) | |||
| 240 | + | }() | |||
| 241 | + | return url, nil | |||
| 242 | + | } | |||
| 243 | + | ||||
| 206 | // StartManualOAuth begins an OAuth flow but does NOT start a local | 244 | // StartManualOAuth begins an OAuth flow but does NOT start a local | ||
| 207 | // callback server or open a browser. The returned URL is shown to the | 245 | // callback server or open a browser. The returned URL is shown to the | ||
| 208 | // user so they can complete the authorization on another device; the | 246 | // user so they can complete the authorization on another device; the | ||
| 209 | // resulting code is pasted back via CompleteManualOAuth. | 247 | // resulting code is pasted back via CompleteManualOAuth. | ||
| 210 | func (m *Manager) StartManualOAuth(provider string) (string, error) { | 248 | func (m *Manager) StartManualOAuth(provider string) (string, error) { | ||
| 249 | + | if provider == "kimi" { | |||
| 250 | + | return m.StartKimiDeviceOAuth() | |||
| 251 | + | } | |||
| 211 | var op OAuthProvider | 252 | var op OAuthProvider | ||
| 212 | switch provider { | 253 | switch provider { | ||
| 213 | case "anthropic": | 254 | case "anthropic": | ||
| @@ -215,7 +256,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||||
| 215 | case "openai": | 256 | case "openai": | ||
| 216 | op = OpenAIOAuth | 257 | op = OpenAIOAuth | ||
| 217 | default: | 258 | default: | ||
| 218 | - | return "", fmt.Errorf("provider must be anthropic or openai") | 259 | + | return "", fmt.Errorf("provider must be anthropic, openai, or kimi") |
| 219 | } | 260 | } | ||
| 220 | 261 | ||||
| 221 | pkce, err := NewPKCE() | 262 | pkce, err := NewPKCE() | ||
M internal/auth/oauth.go +8 -0
| @@ -111,6 +111,14 @@ var ( | |||
| 111 | 111 | "originator": "zot", | |
| 112 | 112 | }, | |
| 113 | 113 | } | |
| 114 | + | ||
| 115 | + | // Kimi Code subscription: used by the official Kimi Code CLI. | |
| 116 | + | // Kimi uses OAuth 2 device-code flow instead of a loopback callback. | |
| 117 | + | KimiOAuth = OAuthProvider{ | |
| 118 | + | Name: "kimi", | |
| 119 | + | TokenURL: "https://auth.kimi.com/api/oauth/token", | |
| 120 | + | ClientID: "17e5f671-d194-4dfb-9706-5516cb48c098", | |
| 121 | + | } | |
| 114 | 122 | ) | |
| 115 | 123 | ||
| 116 | 124 | // PKCE holds a verifier/challenge pair. | |
| @@ -111,6 +111,14 @@ var ( | |||||
| 111 | "originator": "zot", | 111 | "originator": "zot", | ||
| 112 | }, | 112 | }, | ||
| 113 | } | 113 | } | ||
| 114 | + | ||||
| 115 | + | // Kimi Code subscription: used by the official Kimi Code CLI. | |||
| 116 | + | // Kimi uses OAuth 2 device-code flow instead of a loopback callback. | |||
| 117 | + | KimiOAuth = OAuthProvider{ | |||
| 118 | + | Name: "kimi", | |||
| 119 | + | TokenURL: "https://auth.kimi.com/api/oauth/token", | |||
| 120 | + | ClientID: "17e5f671-d194-4dfb-9706-5516cb48c098", | |||
| 121 | + | } | |||
| 114 | ) | 122 | ) | ||
| 115 | 123 | ||||
| 116 | // PKCE holds a verifier/challenge pair. | 124 | // PKCE holds a verifier/challenge pair. | ||
M internal/auth/probe.go +6 -0
| @@ -31,6 +31,12 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||
| 31 | 31 | return err | |
| 32 | 32 | } | |
| 33 | 33 | req.Header.Set("authorization", "Bearer "+key) | |
| 34 | + | case "kimi": | |
| 35 | + | req, err = http.NewRequestWithContext(ctx, "GET", "https://api.kimi.com/coding/v1/models", nil) | |
| 36 | + | if err != nil { | |
| 37 | + | return err | |
| 38 | + | } | |
| 39 | + | req.Header.Set("authorization", "Bearer "+key) | |
| 34 | 40 | default: | |
| 35 | 41 | return fmt.Errorf("unknown provider %q", provider) | |
| 36 | 42 | } | |
| @@ -31,6 +31,12 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||||
| 31 | return err | 31 | return err | ||
| 32 | } | 32 | } | ||
| 33 | req.Header.Set("authorization", "Bearer "+key) | 33 | req.Header.Set("authorization", "Bearer "+key) | ||
| 34 | + | case "kimi": | |||
| 35 | + | req, err = http.NewRequestWithContext(ctx, "GET", "https://api.kimi.com/coding/v1/models", nil) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return err | |||
| 38 | + | } | |||
| 39 | + | req.Header.Set("authorization", "Bearer "+key) | |||
| 34 | default: | 40 | default: | ||
| 35 | return fmt.Errorf("unknown provider %q", provider) | 41 | return fmt.Errorf("unknown provider %q", provider) | ||
| 36 | } | 42 | } | ||
M internal/auth/server.go +6 -5
| @@ -111,8 +111,8 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) { | |||
| 111 | 111 | switch r.Method { | |
| 112 | 112 | case http.MethodGet: | |
| 113 | 113 | provider := r.URL.Query().Get("provider") | |
| 114 | - | if provider != "anthropic" && provider != "openai" { | |
| 115 | - | http.Error(w, "provider must be anthropic or openai", http.StatusBadRequest) | |
| 114 | + | if provider != "anthropic" && provider != "openai" && provider != "kimi" { | |
| 115 | + | http.Error(w, "provider must be anthropic, openai, or kimi", http.StatusBadRequest) | |
| 116 | 116 | return | |
| 117 | 117 | } | |
| 118 | 118 | 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=" | |||
| 223 | 223 | ` + logoTag + ` | |
| 224 | 224 | <h1><span class="zot">zot</span> login</h1> | |
| 225 | 225 | <hr class="rule"> | |
| 226 | - | <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> | |
| 226 | + | <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> | |
| 227 | 227 | <p> | |
| 228 | 228 | <a href="/apikey?provider=anthropic">anthropic api key →</a><br> | |
| 229 | - | <a href="/apikey?provider=openai">openai api key →</a> | |
| 229 | + | <a href="/apikey?provider=openai">openai api key →</a><br> | |
| 230 | + | <a href="/apikey?provider=kimi">kimi api key →</a> | |
| 230 | 231 | </p> | |
| 231 | 232 | <hr class="rule"> | |
| 232 | - | <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> | |
| 233 | + | <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> | |
| 233 | 234 | </body></html>`)) | |
| 234 | 235 | ||
| 235 | 236 | func init() { | |
| @@ -111,8 +111,8 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) { | |||||
| 111 | switch r.Method { | 111 | switch r.Method { | ||
| 112 | case http.MethodGet: | 112 | case http.MethodGet: | ||
| 113 | provider := r.URL.Query().Get("provider") | 113 | provider := r.URL.Query().Get("provider") | ||
| 114 | - | if provider != "anthropic" && provider != "openai" { | 114 | + | if provider != "anthropic" && provider != "openai" && provider != "kimi" { |
| 115 | - | http.Error(w, "provider must be anthropic or openai", http.StatusBadRequest) | 115 | + | http.Error(w, "provider must be anthropic, openai, or kimi", http.StatusBadRequest) |
| 116 | return | 116 | return | ||
| 117 | } | 117 | } | ||
| 118 | tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider}) | 118 | 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=" | |||||
| 223 | ` + logoTag + ` | 223 | ` + logoTag + ` | ||
| 224 | <h1><span class="zot">zot</span> login</h1> | 224 | <h1><span class="zot">zot</span> login</h1> | ||
| 225 | <hr class="rule"> | 225 | <hr class="rule"> | ||
| 226 | - | <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> | 226 | + | <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> |
| 227 | <p> | 227 | <p> | ||
| 228 | <a href="/apikey?provider=anthropic">anthropic api key →</a><br> | 228 | <a href="/apikey?provider=anthropic">anthropic api key →</a><br> | ||
| 229 | - | <a href="/apikey?provider=openai">openai api key →</a> | 229 | + | <a href="/apikey?provider=openai">openai api key →</a><br> |
| 230 | + | <a href="/apikey?provider=kimi">kimi api key →</a> | |||
| 230 | </p> | 231 | </p> | ||
| 231 | <hr class="rule"> | 232 | <hr class="rule"> | ||
| 232 | - | <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> | 233 | + | <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> |
| 233 | </body></html>`)) | 234 | </body></html>`)) | ||
| 234 | 235 | ||||
| 235 | func init() { | 236 | func init() { | ||
M internal/auth/store.go +3 -0
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | type Credentials struct { | |
| 19 | 19 | Anthropic ProviderCreds `json:"anthropic,omitempty"` | |
| 20 | 20 | OpenAI ProviderCreds `json:"openai,omitempty"` | |
| 21 | + | Kimi ProviderCreds `json:"kimi,omitempty"` | |
| 21 | 22 | } | |
| 22 | 23 | ||
| 23 | 24 | // ProviderCreds holds credentials for a single provider. Only one of | |
| @@ -80,6 +81,8 @@ func (c *Credentials) get(provider string) *ProviderCreds { | |||
| 80 | 81 | return &c.Anthropic | |
| 81 | 82 | case "openai": | |
| 82 | 83 | return &c.OpenAI | |
| 84 | + | case "kimi": | |
| 85 | + | return &c.Kimi | |
| 83 | 86 | } | |
| 84 | 87 | return nil | |
| 85 | 88 | } | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | type Credentials struct { | 18 | type Credentials struct { | ||
| 19 | Anthropic ProviderCreds `json:"anthropic,omitempty"` | 19 | Anthropic ProviderCreds `json:"anthropic,omitempty"` | ||
| 20 | OpenAI ProviderCreds `json:"openai,omitempty"` | 20 | OpenAI ProviderCreds `json:"openai,omitempty"` | ||
| 21 | + | Kimi ProviderCreds `json:"kimi,omitempty"` | |||
| 21 | } | 22 | } | ||
| 22 | 23 | ||||
| 23 | // ProviderCreds holds credentials for a single provider. Only one of | 24 | // ProviderCreds holds credentials for a single provider. Only one of | ||
| @@ -80,6 +81,8 @@ func (c *Credentials) get(provider string) *ProviderCreds { | |||||
| 80 | return &c.Anthropic | 81 | return &c.Anthropic | ||
| 81 | case "openai": | 82 | case "openai": | ||
| 82 | return &c.OpenAI | 83 | return &c.OpenAI | ||
| 84 | + | case "kimi": | |||
| 85 | + | return &c.Kimi | |||
| 83 | } | 86 | } | ||
| 84 | return nil | 87 | return nil | ||
| 85 | } | 88 | } | ||
M internal/provider/models.go +7 -0
| @@ -92,6 +92,13 @@ var Catalog = []Model{ | |||
| 92 | 92 | PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75, | |
| 93 | 93 | }, | |
| 94 | 94 | ||
| 95 | + | // ---- Kimi / Kimi Code ---- | |
| 96 | + | { | |
| 97 | + | Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi-k2.6", | |
| 98 | + | ContextWindow: 262144, MaxOutput: 32000, Reasoning: true, | |
| 99 | + | BaseURL: "https://api.kimi.com/coding/v1", | |
| 100 | + | }, | |
| 101 | + | ||
| 95 | 102 | // ---- OpenAI / GPT-5 family ---- | |
| 96 | 103 | { | |
| 97 | 104 | Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5", | |
| @@ -92,6 +92,13 @@ var Catalog = []Model{ | |||||
| 92 | PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75, | 92 | PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75, | ||
| 93 | }, | 93 | }, | ||
| 94 | 94 | ||||
| 95 | + | // ---- Kimi / Kimi Code ---- | |||
| 96 | + | { | |||
| 97 | + | Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi-k2.6", | |||
| 98 | + | ContextWindow: 262144, MaxOutput: 32000, Reasoning: true, | |||
| 99 | + | BaseURL: "https://api.kimi.com/coding/v1", | |||
| 100 | + | }, | |||
| 101 | + | ||||
| 95 | // ---- OpenAI / GPT-5 family ---- | 102 | // ---- OpenAI / GPT-5 family ---- | ||
| 96 | { | 103 | { | ||
| 97 | Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5", | 104 | Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5", | ||
M internal/provider/openai.go +39 -3
| @@ -17,7 +17,9 @@ const openaiDefaultBaseURL = "https://api.openai.com" | |||
| 17 | 17 | type openaiClient struct { | |
| 18 | 18 | apiKey string | |
| 19 | 19 | baseURL string | |
| 20 | + | name string | |
| 20 | 21 | oauth bool // when true, apiKey actually holds an OAuth access token | |
| 22 | + | headers map[string]string | |
| 21 | 23 | http *http.Client | |
| 22 | 24 | } | |
| 23 | 25 | ||
| @@ -29,6 +31,7 @@ func NewOpenAI(apiKey, baseURL string) Client { | |||
| 29 | 31 | return &openaiClient{ | |
| 30 | 32 | apiKey: apiKey, | |
| 31 | 33 | baseURL: strings.TrimRight(baseURL, "/"), | |
| 34 | + | name: "openai", | |
| 32 | 35 | http: &http.Client{Timeout: 0}, | |
| 33 | 36 | } | |
| 34 | 37 | } | |
| @@ -42,12 +45,38 @@ func NewOpenAIOAuth(accessToken, baseURL string) Client { | |||
| 42 | 45 | return &openaiClient{ | |
| 43 | 46 | apiKey: accessToken, | |
| 44 | 47 | baseURL: strings.TrimRight(baseURL, "/"), | |
| 48 | + | name: "openai", | |
| 45 | 49 | oauth: true, | |
| 46 | 50 | http: &http.Client{Timeout: 0}, | |
| 47 | 51 | } | |
| 48 | 52 | } | |
| 49 | 53 | ||
| 50 | - | func (c *openaiClient) Name() string { return "openai" } | |
| 54 | + | // NewKimi creates a Kimi/Moonshot client. Kimi's chat API is OpenAI-compatible. | |
| 55 | + | func NewKimi(apiKey, baseURL string) Client { | |
| 56 | + | return NewKimiWithHeaders(apiKey, baseURL, nil) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers. | |
| 60 | + | // Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers. | |
| 61 | + | func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client { | |
| 62 | + | if baseURL == "" { | |
| 63 | + | baseURL = "https://api.kimi.com/coding/v1" | |
| 64 | + | } | |
| 65 | + | return &openaiClient{ | |
| 66 | + | apiKey: apiKey, | |
| 67 | + | baseURL: strings.TrimRight(baseURL, "/"), | |
| 68 | + | name: "kimi", | |
| 69 | + | headers: headers, | |
| 70 | + | http: &http.Client{Timeout: 0}, | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (c *openaiClient) Name() string { | |
| 75 | + | if c.name != "" { | |
| 76 | + | return c.name | |
| 77 | + | } | |
| 78 | + | return "openai" | |
| 79 | + | } | |
| 51 | 80 | ||
| 52 | 81 | // ---- wire types ---- | |
| 53 | 82 | ||
| @@ -285,6 +314,10 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} { | |||
| 285 | 314 | // ---- streaming ---- | |
| 286 | 315 | ||
| 287 | 316 | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 317 | + | apiPath := "/v1/chat/completions" | |
| 318 | + | if strings.HasSuffix(c.baseURL, "/v1") { | |
| 319 | + | apiPath = "/chat/completions" | |
| 320 | + | } | |
| 288 | 321 | wire, err := c.buildRequest(req) | |
| 289 | 322 | if err != nil { | |
| 290 | 323 | return nil, err | |
| @@ -293,13 +326,16 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||
| 293 | 326 | if err != nil { | |
| 294 | 327 | return nil, err | |
| 295 | 328 | } | |
| 296 | - | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) | |
| 329 | + | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body)) | |
| 297 | 330 | if err != nil { | |
| 298 | 331 | return nil, err | |
| 299 | 332 | } | |
| 300 | 333 | httpReq.Header.Set("content-type", "application/json") | |
| 301 | 334 | httpReq.Header.Set("accept", "text/event-stream") | |
| 302 | 335 | httpReq.Header.Set("authorization", "Bearer "+c.apiKey) | |
| 336 | + | for k, v := range c.headers { | |
| 337 | + | httpReq.Header.Set(k, v) | |
| 338 | + | } | |
| 303 | 339 | ||
| 304 | 340 | resp, err := c.http.Do(httpReq) | |
| 305 | 341 | if err != nil { | |
| @@ -321,7 +357,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 321 | 357 | defer resp.Body.Close() | |
| 322 | 358 | ||
| 323 | 359 | model, _ := FindModel("", req.Model) | |
| 324 | - | out <- EventStart{Model: req.Model, Provider: "openai"} | |
| 360 | + | out <- EventStart{Model: req.Model, Provider: c.Name()} | |
| 325 | 361 | ||
| 326 | 362 | raw := make(chan sseEvent, 16) | |
| 327 | 363 | go readSSE(resp.Body, raw) | |
| @@ -17,7 +17,9 @@ const openaiDefaultBaseURL = "https://api.openai.com" | |||||
| 17 | type openaiClient struct { | 17 | type openaiClient struct { | ||
| 18 | apiKey string | 18 | apiKey string | ||
| 19 | baseURL string | 19 | baseURL string | ||
| 20 | + | name string | |||
| 20 | oauth bool // when true, apiKey actually holds an OAuth access token | 21 | oauth bool // when true, apiKey actually holds an OAuth access token | ||
| 22 | + | headers map[string]string | |||
| 21 | http *http.Client | 23 | http *http.Client | ||
| 22 | } | 24 | } | ||
| 23 | 25 | ||||
| @@ -29,6 +31,7 @@ func NewOpenAI(apiKey, baseURL string) Client { | |||||
| 29 | return &openaiClient{ | 31 | return &openaiClient{ | ||
| 30 | apiKey: apiKey, | 32 | apiKey: apiKey, | ||
| 31 | baseURL: strings.TrimRight(baseURL, "/"), | 33 | baseURL: strings.TrimRight(baseURL, "/"), | ||
| 34 | + | name: "openai", | |||
| 32 | http: &http.Client{Timeout: 0}, | 35 | http: &http.Client{Timeout: 0}, | ||
| 33 | } | 36 | } | ||
| 34 | } | 37 | } | ||
| @@ -42,12 +45,38 @@ func NewOpenAIOAuth(accessToken, baseURL string) Client { | |||||
| 42 | return &openaiClient{ | 45 | return &openaiClient{ | ||
| 43 | apiKey: accessToken, | 46 | apiKey: accessToken, | ||
| 44 | baseURL: strings.TrimRight(baseURL, "/"), | 47 | baseURL: strings.TrimRight(baseURL, "/"), | ||
| 48 | + | name: "openai", | |||
| 45 | oauth: true, | 49 | oauth: true, | ||
| 46 | http: &http.Client{Timeout: 0}, | 50 | http: &http.Client{Timeout: 0}, | ||
| 47 | } | 51 | } | ||
| 48 | } | 52 | } | ||
| 49 | 53 | ||||
| 50 | - | func (c *openaiClient) Name() string { return "openai" } | 54 | + | // NewKimi creates a Kimi/Moonshot client. Kimi's chat API is OpenAI-compatible. |
| 55 | + | func NewKimi(apiKey, baseURL string) Client { | |||
| 56 | + | return NewKimiWithHeaders(apiKey, baseURL, nil) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers. | |||
| 60 | + | // Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers. | |||
| 61 | + | func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client { | |||
| 62 | + | if baseURL == "" { | |||
| 63 | + | baseURL = "https://api.kimi.com/coding/v1" | |||
| 64 | + | } | |||
| 65 | + | return &openaiClient{ | |||
| 66 | + | apiKey: apiKey, | |||
| 67 | + | baseURL: strings.TrimRight(baseURL, "/"), | |||
| 68 | + | name: "kimi", | |||
| 69 | + | headers: headers, | |||
| 70 | + | http: &http.Client{Timeout: 0}, | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (c *openaiClient) Name() string { | |||
| 75 | + | if c.name != "" { | |||
| 76 | + | return c.name | |||
| 77 | + | } | |||
| 78 | + | return "openai" | |||
| 79 | + | } | |||
| 51 | 80 | ||||
| 52 | // ---- wire types ---- | 81 | // ---- wire types ---- | ||
| 53 | 82 | ||||
| @@ -285,6 +314,10 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} { | |||||
| 285 | // ---- streaming ---- | 314 | // ---- streaming ---- | ||
| 286 | 315 | ||||
| 287 | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 316 | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 317 | + | apiPath := "/v1/chat/completions" | |||
| 318 | + | if strings.HasSuffix(c.baseURL, "/v1") { | |||
| 319 | + | apiPath = "/chat/completions" | |||
| 320 | + | } | |||
| 288 | wire, err := c.buildRequest(req) | 321 | wire, err := c.buildRequest(req) | ||
| 289 | if err != nil { | 322 | if err != nil { | ||
| 290 | return nil, err | 323 | return nil, err | ||
| @@ -293,13 +326,16 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||||
| 293 | if err != nil { | 326 | if err != nil { | ||
| 294 | return nil, err | 327 | return nil, err | ||
| 295 | } | 328 | } | ||
| 296 | - | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) | 329 | + | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body)) |
| 297 | if err != nil { | 330 | if err != nil { | ||
| 298 | return nil, err | 331 | return nil, err | ||
| 299 | } | 332 | } | ||
| 300 | httpReq.Header.Set("content-type", "application/json") | 333 | httpReq.Header.Set("content-type", "application/json") | ||
| 301 | httpReq.Header.Set("accept", "text/event-stream") | 334 | httpReq.Header.Set("accept", "text/event-stream") | ||
| 302 | httpReq.Header.Set("authorization", "Bearer "+c.apiKey) | 335 | httpReq.Header.Set("authorization", "Bearer "+c.apiKey) | ||
| 336 | + | for k, v := range c.headers { | |||
| 337 | + | httpReq.Header.Set(k, v) | |||
| 338 | + | } | |||
| 303 | 339 | ||||
| 304 | resp, err := c.http.Do(httpReq) | 340 | resp, err := c.http.Do(httpReq) | ||
| 305 | if err != nil { | 341 | if err != nil { | ||
| @@ -321,7 +357,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 321 | defer resp.Body.Close() | 357 | defer resp.Body.Close() | ||
| 322 | 358 | ||||
| 323 | model, _ := FindModel("", req.Model) | 359 | model, _ := FindModel("", req.Model) | ||
| 324 | - | out <- EventStart{Model: req.Model, Provider: "openai"} | 360 | + | out <- EventStart{Model: req.Model, Provider: c.Name()} |
| 325 | 361 | ||||
| 326 | raw := make(chan sseEvent, 16) | 362 | raw := make(chan sseEvent, 16) | ||
| 327 | go readSSE(resp.Body, raw) | 363 | go readSSE(resp.Body, raw) | ||
M internal/provider/usermodels.go +2 -0
| @@ -78,6 +78,8 @@ func LoadUserModels(path string) []Model { | |||
| 78 | 78 | normalized = "openai" | |
| 79 | 79 | case "anthropic-messages": | |
| 80 | 80 | normalized = "anthropic" | |
| 81 | + | case "moonshot", "moonshot-ai", "kimi-code": | |
| 82 | + | normalized = "kimi" | |
| 81 | 83 | } | |
| 82 | 84 | ||
| 83 | 85 | for _, um := range prov.Models { | |
| @@ -78,6 +78,8 @@ func LoadUserModels(path string) []Model { | |||||
| 78 | normalized = "openai" | 78 | normalized = "openai" | ||
| 79 | case "anthropic-messages": | 79 | case "anthropic-messages": | ||
| 80 | normalized = "anthropic" | 80 | normalized = "anthropic" | ||
| 81 | + | case "moonshot", "moonshot-ai", "kimi-code": | |||
| 82 | + | normalized = "kimi" | |||
| 81 | } | 83 | } | ||
| 82 | 84 | ||||
| 83 | for _, um := range prov.Models { | 85 | for _, um := range prov.Models { | ||
M internal/tui/view.go +105 -0
| @@ -7,7 +7,9 @@ import ( | |||
| 7 | 7 | "regexp" | |
| 8 | 8 | "strconv" | |
| 9 | 9 | "strings" | |
| 10 | + | "unicode/utf8" | |
| 10 | 11 | ||
| 12 | + | "github.com/mattn/go-runewidth" | |
| 11 | 13 | "github.com/patriceckhart/zot/internal/provider" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| @@ -1440,6 +1442,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||
| 1440 | 1442 | // in muted type, everything else on the default tool-output color. | |
| 1441 | 1443 | // Called from renderToolText when the first line starts with "$ ". | |
| 1442 | 1444 | func (v *View) renderBashResult(lines []string, width, defaultColor int) []string { | |
| 1445 | + | lines = normalizeBashOutputLines(lines) | |
| 1443 | 1446 | // Identify the footer line (exit + timing). The bash tool writes | |
| 1444 | 1447 | // it as the last non-empty line of the result. | |
| 1445 | 1448 | footerIdx := -1 | |
| @@ -1477,6 +1480,108 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin | |||
| 1477 | 1480 | return out | |
| 1478 | 1481 | } | |
| 1479 | 1482 | ||
| 1483 | + | // normalizeBashOutputLines turns arbitrary terminal output into plain, | |
| 1484 | + | // box-safe rows before wrapping. Unlike read/write/edit output, bash | |
| 1485 | + | // stdout/stderr may contain tabs, carriage returns, ANSI/OSC escapes, | |
| 1486 | + | // and other C0 controls from subprocesses. If those reach the bordered | |
| 1487 | + | // tool box, the width calculator can undercount what the terminal will | |
| 1488 | + | // draw and the right edge appears broken. Keep printable text, expand | |
| 1489 | + | // tabs to spaces, split carriage-return progress rows, and drop escape | |
| 1490 | + | // / control sequences. | |
| 1491 | + | func normalizeBashOutputLines(lines []string) []string { | |
| 1492 | + | var out []string | |
| 1493 | + | for _, line := range lines { | |
| 1494 | + | for _, part := range strings.Split(strings.ReplaceAll(line, "\r", "\n"), "\n") { | |
| 1495 | + | out = append(out, normalizeBashOutputLine(part)) | |
| 1496 | + | } | |
| 1497 | + | } | |
| 1498 | + | if len(out) == 0 { | |
| 1499 | + | return []string{""} | |
| 1500 | + | } | |
| 1501 | + | return out | |
| 1502 | + | } | |
| 1503 | + | ||
| 1504 | + | func normalizeBashOutputLine(s string) string { | |
| 1505 | + | var b strings.Builder | |
| 1506 | + | col := 0 | |
| 1507 | + | for i := 0; i < len(s); { | |
| 1508 | + | c := s[i] | |
| 1509 | + | if c == 0x1b { // ESC: strip CSI/OSC/DCS and simple escapes. | |
| 1510 | + | i = skipEscapeSequence(s, i) | |
| 1511 | + | continue | |
| 1512 | + | } | |
| 1513 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 1514 | + | if r == utf8.RuneError && size == 1 { | |
| 1515 | + | i++ | |
| 1516 | + | continue | |
| 1517 | + | } | |
| 1518 | + | switch r { | |
| 1519 | + | case '\t': | |
| 1520 | + | spaces := 8 - (col % 8) | |
| 1521 | + | if spaces == 0 { | |
| 1522 | + | spaces = 8 | |
| 1523 | + | } | |
| 1524 | + | b.WriteString(strings.Repeat(" ", spaces)) | |
| 1525 | + | col += spaces | |
| 1526 | + | case '\b': | |
| 1527 | + | // Backspace-overstrike output is common in spinners/progress bars. | |
| 1528 | + | // Dropping it is safer than moving the TUI cursor backwards. | |
| 1529 | + | case '\n', '\r': | |
| 1530 | + | // Already split by normalizeBashOutputLines. | |
| 1531 | + | default: | |
| 1532 | + | if r < 0x20 || r == 0x7f { | |
| 1533 | + | // Drop non-printing controls. | |
| 1534 | + | break | |
| 1535 | + | } | |
| 1536 | + | b.WriteRune(r) | |
| 1537 | + | col += runewidth.RuneWidth(r) | |
| 1538 | + | } | |
| 1539 | + | i += size | |
| 1540 | + | } | |
| 1541 | + | return b.String() | |
| 1542 | + | } | |
| 1543 | + | ||
| 1544 | + | func skipEscapeSequence(s string, i int) int { | |
| 1545 | + | if i >= len(s) || s[i] != 0x1b { | |
| 1546 | + | return i + 1 | |
| 1547 | + | } | |
| 1548 | + | if i+1 >= len(s) { | |
| 1549 | + | return len(s) | |
| 1550 | + | } | |
| 1551 | + | switch s[i+1] { | |
| 1552 | + | case '[': // CSI: ESC [ ... final byte 0x40-0x7e | |
| 1553 | + | j := i + 2 | |
| 1554 | + | for j < len(s) { | |
| 1555 | + | c := s[j] | |
| 1556 | + | j++ | |
| 1557 | + | if c >= 0x40 && c <= 0x7e { | |
| 1558 | + | break | |
| 1559 | + | } | |
| 1560 | + | } | |
| 1561 | + | return j | |
| 1562 | + | case ']': // OSC: ESC ] ... BEL or ST | |
| 1563 | + | return skipStringEscape(s, i+2) | |
| 1564 | + | case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc. | |
| 1565 | + | return skipStringEscape(s, i+2) | |
| 1566 | + | default: | |
| 1567 | + | // Two-byte escape (cursor save/restore, charset select, etc.). | |
| 1568 | + | return i + 2 | |
| 1569 | + | } | |
| 1570 | + | } | |
| 1571 | + | ||
| 1572 | + | func skipStringEscape(s string, i int) int { | |
| 1573 | + | for i < len(s) { | |
| 1574 | + | if s[i] == 0x07 { // BEL | |
| 1575 | + | return i + 1 | |
| 1576 | + | } | |
| 1577 | + | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST | |
| 1578 | + | return i + 2 | |
| 1579 | + | } | |
| 1580 | + | i++ | |
| 1581 | + | } | |
| 1582 | + | return len(s) | |
| 1583 | + | } | |
| 1584 | + | ||
| 1480 | 1585 | // looksLikeUnifiedDiff reports whether text is a context diff as | |
| 1481 | 1586 | // emitted by the edit tool: rows start with '+', '-', ' ', or | |
| 1482 | 1587 | // literal "..." (context-break marker). The presence of at least | |
| @@ -7,7 +7,9 @@ import ( | |||||
| 7 | "regexp" | 7 | "regexp" | ||
| 8 | "strconv" | 8 | "strconv" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | + | "unicode/utf8" | |||
| 10 | 11 | ||||
| 12 | + | "github.com/mattn/go-runewidth" | |||
| 11 | "github.com/patriceckhart/zot/internal/provider" | 13 | "github.com/patriceckhart/zot/internal/provider" | ||
| 12 | ) | 14 | ) | ||
| 13 | 15 | ||||
| @@ -1440,6 +1442,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||||
| 1440 | // in muted type, everything else on the default tool-output color. | 1442 | // in muted type, everything else on the default tool-output color. | ||
| 1441 | // Called from renderToolText when the first line starts with "$ ". | 1443 | // Called from renderToolText when the first line starts with "$ ". | ||
| 1442 | func (v *View) renderBashResult(lines []string, width, defaultColor int) []string { | 1444 | func (v *View) renderBashResult(lines []string, width, defaultColor int) []string { | ||
| 1445 | + | lines = normalizeBashOutputLines(lines) | |||
| 1443 | // Identify the footer line (exit + timing). The bash tool writes | 1446 | // Identify the footer line (exit + timing). The bash tool writes | ||
| 1444 | // it as the last non-empty line of the result. | 1447 | // it as the last non-empty line of the result. | ||
| 1445 | footerIdx := -1 | 1448 | footerIdx := -1 | ||
| @@ -1477,6 +1480,108 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin | |||||
| 1477 | return out | 1480 | return out | ||
| 1478 | } | 1481 | } | ||
| 1479 | 1482 | ||||
| 1483 | + | // normalizeBashOutputLines turns arbitrary terminal output into plain, | |||
| 1484 | + | // box-safe rows before wrapping. Unlike read/write/edit output, bash | |||
| 1485 | + | // stdout/stderr may contain tabs, carriage returns, ANSI/OSC escapes, | |||
| 1486 | + | // and other C0 controls from subprocesses. If those reach the bordered | |||
| 1487 | + | // tool box, the width calculator can undercount what the terminal will | |||
| 1488 | + | // draw and the right edge appears broken. Keep printable text, expand | |||
| 1489 | + | // tabs to spaces, split carriage-return progress rows, and drop escape | |||
| 1490 | + | // / control sequences. | |||
| 1491 | + | func normalizeBashOutputLines(lines []string) []string { | |||
| 1492 | + | var out []string | |||
| 1493 | + | for _, line := range lines { | |||
| 1494 | + | for _, part := range strings.Split(strings.ReplaceAll(line, "\r", "\n"), "\n") { | |||
| 1495 | + | out = append(out, normalizeBashOutputLine(part)) | |||
| 1496 | + | } | |||
| 1497 | + | } | |||
| 1498 | + | if len(out) == 0 { | |||
| 1499 | + | return []string{""} | |||
| 1500 | + | } | |||
| 1501 | + | return out | |||
| 1502 | + | } | |||
| 1503 | + | ||||
| 1504 | + | func normalizeBashOutputLine(s string) string { | |||
| 1505 | + | var b strings.Builder | |||
| 1506 | + | col := 0 | |||
| 1507 | + | for i := 0; i < len(s); { | |||
| 1508 | + | c := s[i] | |||
| 1509 | + | if c == 0x1b { // ESC: strip CSI/OSC/DCS and simple escapes. | |||
| 1510 | + | i = skipEscapeSequence(s, i) | |||
| 1511 | + | continue | |||
| 1512 | + | } | |||
| 1513 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |||
| 1514 | + | if r == utf8.RuneError && size == 1 { | |||
| 1515 | + | i++ | |||
| 1516 | + | continue | |||
| 1517 | + | } | |||
| 1518 | + | switch r { | |||
| 1519 | + | case '\t': | |||
| 1520 | + | spaces := 8 - (col % 8) | |||
| 1521 | + | if spaces == 0 { | |||
| 1522 | + | spaces = 8 | |||
| 1523 | + | } | |||
| 1524 | + | b.WriteString(strings.Repeat(" ", spaces)) | |||
| 1525 | + | col += spaces | |||
| 1526 | + | case '\b': | |||
| 1527 | + | // Backspace-overstrike output is common in spinners/progress bars. | |||
| 1528 | + | // Dropping it is safer than moving the TUI cursor backwards. | |||
| 1529 | + | case '\n', '\r': | |||
| 1530 | + | // Already split by normalizeBashOutputLines. | |||
| 1531 | + | default: | |||
| 1532 | + | if r < 0x20 || r == 0x7f { | |||
| 1533 | + | // Drop non-printing controls. | |||
| 1534 | + | break | |||
| 1535 | + | } | |||
| 1536 | + | b.WriteRune(r) | |||
| 1537 | + | col += runewidth.RuneWidth(r) | |||
| 1538 | + | } | |||
| 1539 | + | i += size | |||
| 1540 | + | } | |||
| 1541 | + | return b.String() | |||
| 1542 | + | } | |||
| 1543 | + | ||||
| 1544 | + | func skipEscapeSequence(s string, i int) int { | |||
| 1545 | + | if i >= len(s) || s[i] != 0x1b { | |||
| 1546 | + | return i + 1 | |||
| 1547 | + | } | |||
| 1548 | + | if i+1 >= len(s) { | |||
| 1549 | + | return len(s) | |||
| 1550 | + | } | |||
| 1551 | + | switch s[i+1] { | |||
| 1552 | + | case '[': // CSI: ESC [ ... final byte 0x40-0x7e | |||
| 1553 | + | j := i + 2 | |||
| 1554 | + | for j < len(s) { | |||
| 1555 | + | c := s[j] | |||
| 1556 | + | j++ | |||
| 1557 | + | if c >= 0x40 && c <= 0x7e { | |||
| 1558 | + | break | |||
| 1559 | + | } | |||
| 1560 | + | } | |||
| 1561 | + | return j | |||
| 1562 | + | case ']': // OSC: ESC ] ... BEL or ST | |||
| 1563 | + | return skipStringEscape(s, i+2) | |||
| 1564 | + | case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc. | |||
| 1565 | + | return skipStringEscape(s, i+2) | |||
| 1566 | + | default: | |||
| 1567 | + | // Two-byte escape (cursor save/restore, charset select, etc.). | |||
| 1568 | + | return i + 2 | |||
| 1569 | + | } | |||
| 1570 | + | } | |||
| 1571 | + | ||||
| 1572 | + | func skipStringEscape(s string, i int) int { | |||
| 1573 | + | for i < len(s) { | |||
| 1574 | + | if s[i] == 0x07 { // BEL | |||
| 1575 | + | return i + 1 | |||
| 1576 | + | } | |||
| 1577 | + | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST | |||
| 1578 | + | return i + 2 | |||
| 1579 | + | } | |||
| 1580 | + | i++ | |||
| 1581 | + | } | |||
| 1582 | + | return len(s) | |||
| 1583 | + | } | |||
| 1584 | + | ||||
| 1480 | // looksLikeUnifiedDiff reports whether text is a context diff as | 1585 | // looksLikeUnifiedDiff reports whether text is a context diff as | ||
| 1481 | // emitted by the edit tool: rows start with '+', '-', ' ', or | 1586 | // emitted by the edit tool: rows start with '+', '-', ' ', or | ||
| 1482 | // literal "..." (context-break marker). The presence of at least | 1587 | // literal "..." (context-break marker). The presence of at least | ||