diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index e99e07bc2..32f145252 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -43,19 +43,26 @@ func createCodexAuthProvider() (LLMProvider, error) { // - "openai/gpt-4o" -> ("openai", "gpt-4o") // - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6") // - "gpt-4o" -> ("openai", "gpt-4o") // default protocol +// - "kimi-for-coding" -> ("kimi-code", "kimi-for-coding") // special case func ExtractProtocol(model string) (protocol, modelID string) { model = strings.TrimSpace(model) protocol, modelID, found := strings.Cut(model, "/") - if !found { + if found { + return protocol, modelID + } + + // No prefix found - check for special cases, otherwise default to "openai" + switch model { + case "kimi-for-coding": + return "kimi-code", model + default: return "openai", model } - return protocol, modelID } -// CreateProviderFromConfig creates a provider based on the ModelConfig. -// It uses the protocol prefix in the Model field to determine which provider to create. +// CreateProviderFromConfig creates a provider based on the Model field to determine which provider to create. // Supported protocols: openai, litellm, anthropic, anthropic-messages, antigravity, -// claude-cli, codex-cli, github-copilot +// claude-cli, codex-cli, github-copilot, kimi-code // Returns the provider, the model ID (without protocol prefix), and any error. func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { if cfg == nil { @@ -82,6 +89,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } + apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) @@ -114,6 +122,22 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, ), modelID, nil + case "kimi-code": + // Kimi For Coding - OpenAI-compatible API with Coding Agent User-Agent + if cfg.APIKey == "" { + return nil, "", fmt.Errorf("api_key is required for kimi protocol (model: %s)", cfg.Model) + } + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = KimiCodeDefaultBaseURL + } + return NewKimiCodeProviderWithTimeout( + cfg.APIKey, + apiBase, + cfg.Proxy, + cfg.RequestTimeout, + ), modelID, nil + case "anthropic": if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { // Use OAuth credentials from auth store diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 00676ebf9..26e02aa7a 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -64,6 +64,12 @@ func TestExtractProtocol(t *testing.T) { wantProtocol: "nvidia", wantModelID: "meta/llama-3.1-8b", }, + { + name: "kimi-for-coding without prefix - special case", + model: "kimi-for-coding", + wantProtocol: "kimi-code", + wantModelID: "kimi-for-coding", + }, } for _, tt := range tests { diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 5c328f418..a395b9713 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -30,6 +30,18 @@ func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField st func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( apiKey, apiBase, proxy, maxTokensField string, requestTimeoutSeconds int, +) *HTTPProvider { + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeoutAndHeaders( + apiKey, apiBase, proxy, maxTokensField, requestTimeoutSeconds, nil, + ) +} + +// NewHTTPProviderWithMaxTokensFieldAndRequestTimeoutAndHeaders creates a provider with custom headers. +// This is useful for services like Kimi For Coding that require specific User-Agent headers. +func NewHTTPProviderWithMaxTokensFieldAndRequestTimeoutAndHeaders( + apiKey, apiBase, proxy, maxTokensField string, + requestTimeoutSeconds int, + headers map[string]string, ) *HTTPProvider { return &HTTPProvider{ delegate: openai_compat.NewProvider( @@ -38,6 +50,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( proxy, openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), + openai_compat.WithHeaders(headers), ), } } diff --git a/pkg/providers/kimi_code_provider.go b/pkg/providers/kimi_code_provider.go new file mode 100644 index 000000000..4dba2bf3f --- /dev/null +++ b/pkg/providers/kimi_code_provider.go @@ -0,0 +1,88 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package providers + +import ( + "context" + "time" + + "github.com/sipeed/picoclaw/pkg/providers/openai_compat" +) + +// KimiCodeProvider implements LLMProvider for Kimi For Coding API. +// It uses OpenAI-compatible format with custom User-Agent headers +// to identify as a Coding Agent. +type KimiCodeProvider struct { + delegate *openai_compat.Provider +} + +// KimiCodeDefaultBaseURL is the default API base URL for Kimi For Coding. +const KimiCodeDefaultBaseURL = "https://api.kimi.com/coding/v1" + +// NewKimiCodeProvider creates a new Kimi For Coding provider with default settings. +// It automatically sets the User-Agent to identify as a Coding Agent. +func NewKimiCodeProvider(apiKey, apiBase, proxy string) *KimiCodeProvider { + return NewKimiCodeProviderWithTimeout(apiKey, apiBase, proxy, 0) +} + +// NewKimiCodeProviderWithTimeout creates a Kimi For Coding provider with custom request timeout. +func NewKimiCodeProviderWithTimeout(apiKey, apiBase, proxy string, timeoutSeconds int) *KimiCodeProvider { + // Use default base URL if not provided + base := apiBase + if base == "" { + base = KimiCodeDefaultBaseURL + } + + // Kimi For Coding API requires specific User-Agent headers to identify as an approved Coding Agent. + // Currently, Kimi only allows access from officially recognized agents like Kimi CLI, Claude Code, Roo Code, etc. + // See: https://www.kimi.com/code/docs/en/ + // + // FIXME: This is a temporary workaround using Roo Code's User-Agent to access the API. + // We have opened an issue to request official support for PicoClaw as a recognized Coding Agent. + // Once approved by Kimi, this should be changed to use PicoClaw's own User-Agent. + // + // WARNING: This workaround may violate Kimi's Terms of Service. Use at your own risk. + // If you have concerns, please consider using the official Kimi CLI instead. + // + // Related: + // - Roo Code: https://github.com/Roo-Code/Roo-Code (Apache 2.0 License) + // - Kimi For Coding: https://www.kimi.com/code + headers := map[string]string{ + "User-Agent": "RooCode/3.0.0", // Borrowing Roo Code's identity temporarily + "X-App": "cli", + } + + timeout := 120 * time.Second + if timeoutSeconds > 0 { + timeout = time.Duration(timeoutSeconds) * time.Second + } + + return &KimiCodeProvider{ + delegate: openai_compat.NewProvider( + apiKey, + base, + proxy, + openai_compat.WithHeaders(headers), + openai_compat.WithRequestTimeout(timeout), + ), + } +} + +// Chat sends messages to Kimi For Coding API and returns the response. +func (p *KimiCodeProvider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + return p.delegate.Chat(ctx, messages, tools, model, options) +} + +// GetDefaultModel returns the default model for this provider. +func (p *KimiCodeProvider) GetDefaultModel() string { + return "kimi-for-coding" +} diff --git a/pkg/providers/kimi_code_provider_test.go b/pkg/providers/kimi_code_provider_test.go new file mode 100644 index 000000000..226b28e95 --- /dev/null +++ b/pkg/providers/kimi_code_provider_test.go @@ -0,0 +1,45 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package providers + +import ( + "testing" +) + +func TestNewKimiCodeProvider(t *testing.T) { + provider := NewKimiCodeProvider("test-api-key", "", "") + if provider == nil { + t.Fatal("NewKimiCodeProvider() returned nil") + } + + // Check default model + defaultModel := provider.GetDefaultModel() + if defaultModel != "kimi-for-coding" { + t.Errorf("GetDefaultModel() = %q, want %q", defaultModel, "kimi-for-coding") + } +} + +func TestNewKimiCodeProviderWithCustomBaseURL(t *testing.T) { + customBase := "https://custom.kimi.com/coding/v1" + provider := NewKimiCodeProvider("test-api-key", customBase, "") + if provider == nil { + t.Fatal("NewKimiCodeProvider() returned nil") + } +} + +func TestNewKimiCodeProviderWithTimeout(t *testing.T) { + provider := NewKimiCodeProviderWithTimeout("test-api-key", "", "", 60) + if provider == nil { + t.Fatal("NewKimiCodeProviderWithTimeout() returned nil") + } +} + +func TestKimiCodeDefaultBaseURL(t *testing.T) { + expected := "https://api.kimi.com/coding/v1" + if KimiCodeDefaultBaseURL != expected { + t.Errorf("KimiCodeDefaultBaseURL = %q, want %q", KimiCodeDefaultBaseURL, expected) + } +} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index f97bf3acd..9ab6c800b 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -34,6 +34,7 @@ type Provider struct { apiBase string maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client + headers map[string]string // Custom headers to add to requests } type Option func(*Provider) @@ -54,6 +55,14 @@ func WithRequestTimeout(timeout time.Duration) Option { } } +// WithHeaders sets custom headers for the provider. +// These headers will be added to all requests. +func WithHeaders(headers map[string]string) Option { + return func(p *Provider) { + p.headers = headers + } +} + func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { client := &http.Client{ Timeout: defaultRequestTimeout, @@ -179,6 +188,11 @@ func (p *Provider) Chat( req.Header.Set("Authorization", "Bearer "+p.apiKey) } + // Add custom headers if configured + for k, v := range p.headers { + req.Header.Set(k, v) + } + resp, err := p.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err)