mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge remote-tracking branch 'origin/feat/kimi-opencode-providers' into deploy/pi-integration
This commit is contained in:
@@ -428,6 +428,7 @@ type ProvidersConfig struct {
|
||||
Antigravity ProviderConfig `json:"antigravity"`
|
||||
Qwen ProviderConfig `json:"qwen"`
|
||||
Mistral ProviderConfig `json:"mistral"`
|
||||
Opencode ProviderConfig `json:"opencode"`
|
||||
}
|
||||
|
||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||
@@ -451,7 +452,8 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||
p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
|
||||
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||
p.Opencode.APIKey == "" && p.Opencode.APIBase == ""
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||
|
||||
+18
-1
@@ -225,7 +225,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"moonshot", "kimi"},
|
||||
providerNames: []string{"moonshot", "kimi", "kimi-code"},
|
||||
protocol: "moonshot",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
|
||||
@@ -373,6 +373,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"opencode"},
|
||||
protocol: "opencode",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Opencode.APIKey == "" && p.Opencode.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "opencode",
|
||||
Model: "opencode/auto",
|
||||
APIKey: p.Opencode.APIKey,
|
||||
APIBase: p.Opencode.APIBase,
|
||||
Proxy: p.Opencode.Proxy,
|
||||
RequestTimeout: p.Opencode.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Process each provider migration
|
||||
|
||||
@@ -160,6 +160,7 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
||||
Qwen: ProviderConfig{APIKey: "key17"},
|
||||
Mistral: ProviderConfig{APIKey: "key18"},
|
||||
Opencode: ProviderConfig{APIKey: "key19"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -579,6 +580,65 @@ func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Opencode(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: ProvidersConfig{
|
||||
Opencode: ProviderConfig{
|
||||
APIKey: "oc-test-key",
|
||||
APIBase: "https://custom.opencode.ai/v1",
|
||||
Proxy: "http://proxy:9090",
|
||||
RequestTimeout: 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
mc := result[0]
|
||||
if mc.ModelName != "opencode" {
|
||||
t.Errorf("ModelName = %q, want %q", mc.ModelName, "opencode")
|
||||
}
|
||||
if mc.Model != "opencode/auto" {
|
||||
t.Errorf("Model = %q, want %q", mc.Model, "opencode/auto")
|
||||
}
|
||||
if mc.APIKey != "oc-test-key" {
|
||||
t.Errorf("APIKey = %q, want %q", mc.APIKey, "oc-test-key")
|
||||
}
|
||||
if mc.APIBase != "https://custom.opencode.ai/v1" {
|
||||
t.Errorf("APIBase = %q, want %q", mc.APIBase, "https://custom.opencode.ai/v1")
|
||||
}
|
||||
if mc.Proxy != "http://proxy:9090" {
|
||||
t.Errorf("Proxy = %q, want %q", mc.Proxy, "http://proxy:9090")
|
||||
}
|
||||
if mc.RequestTimeout != 60 {
|
||||
t.Errorf("RequestTimeout = %d, want %d", mc.RequestTimeout, 60)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Opencode_APIBaseOnly(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: ProvidersConfig{
|
||||
Opencode: ProviderConfig{
|
||||
APIBase: "https://custom.opencode.ai/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1 (APIBase-only should create entry)", len(result))
|
||||
}
|
||||
|
||||
if result[0].ModelName != "opencode" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "opencode")
|
||||
}
|
||||
}
|
||||
|
||||
// Test for legacy config with protocol prefix in model name
|
||||
func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {
|
||||
cfg := &Config{
|
||||
|
||||
@@ -190,6 +190,28 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||
sel.apiBase = "https://api.mistral.ai/v1"
|
||||
}
|
||||
}
|
||||
case "opencode":
|
||||
if cfg.Providers.Opencode.APIKey != "" || cfg.Providers.Opencode.APIBase != "" {
|
||||
sel.apiKey = cfg.Providers.Opencode.APIKey
|
||||
sel.apiBase = cfg.Providers.Opencode.APIBase
|
||||
sel.proxy = cfg.Providers.Opencode.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://opencode.ai/zen/v1"
|
||||
}
|
||||
}
|
||||
case "kimi", "kimi-code", "moonshot":
|
||||
if cfg.Providers.Moonshot.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.Moonshot.APIKey
|
||||
sel.apiBase = cfg.Providers.Moonshot.APIBase
|
||||
sel.proxy = cfg.Providers.Moonshot.Proxy
|
||||
if sel.apiBase == "" {
|
||||
if providerName == "moonshot" {
|
||||
sel.apiBase = "https://api.moonshot.cn/v1"
|
||||
} else {
|
||||
sel.apiBase = "https://api.kimi.com/coding/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
case "github_copilot", "copilot":
|
||||
sel.providerType = providerTypeGitHubCopilot
|
||||
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
||||
@@ -210,7 +232,11 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||
sel.apiBase = cfg.Providers.Moonshot.APIBase
|
||||
sel.proxy = cfg.Providers.Moonshot.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.moonshot.cn/v1"
|
||||
if strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/") {
|
||||
sel.apiBase = "https://api.moonshot.cn/v1"
|
||||
} else {
|
||||
sel.apiBase = "https://api.kimi.com/coding/v1"
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(model, "openrouter/") ||
|
||||
strings.HasPrefix(model, "anthropic/") ||
|
||||
|
||||
@@ -94,7 +94,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
|
||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||
"volcengine", "vllm", "qwen", "mistral":
|
||||
"volcengine", "vllm", "qwen", "mistral", "opencode":
|
||||
// All other OpenAI-compatible HTTP providers
|
||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||
@@ -208,6 +208,8 @@ func getDefaultAPIBase(protocol string) string {
|
||||
return "http://localhost:8000/v1"
|
||||
case "mistral":
|
||||
return "https://api.mistral.ai/v1"
|
||||
case "opencode":
|
||||
return "https://opencode.ai/zen/v1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||
{"vllm", "vllm"},
|
||||
{"deepseek", "deepseek"},
|
||||
{"ollama", "ollama"},
|
||||
{"opencode", "opencode"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -33,6 +33,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
|
||||
isKimiAPI bool // true when apiBase points to api.kimi.com
|
||||
}
|
||||
|
||||
type Option func(*Provider)
|
||||
@@ -69,10 +70,17 @@ func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
||||
}
|
||||
}
|
||||
|
||||
trimmedBase := strings.TrimRight(apiBase, "/")
|
||||
var isKimi bool
|
||||
if parsed, err := url.Parse(trimmedBase); err == nil {
|
||||
isKimi = parsed.Hostname() == "api.kimi.com"
|
||||
}
|
||||
|
||||
p := &Provider{
|
||||
apiKey: apiKey,
|
||||
apiBase: strings.TrimRight(apiBase, "/"),
|
||||
apiBase: trimmedBase,
|
||||
httpClient: client,
|
||||
isKimiAPI: isKimi,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
@@ -176,6 +184,12 @@ func (p *Provider) Chat(
|
||||
if p.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
// Kimi Code API rejects requests without a recognized coding-agent
|
||||
// User-Agent. "KimiCLI/0.77" is the minimum version string accepted
|
||||
// by the api.kimi.com/coding/v1 endpoint (per Kimi's API docs).
|
||||
if p.isKimiAPI {
|
||||
req.Header.Set("User-Agent", "KimiCLI/0.77")
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package openai_compat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -420,6 +421,82 @@ func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// roundTripFunc adapts a function to http.RoundTripper for test injection.
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
|
||||
func TestProviderChat_KimiCodeUserAgent(t *testing.T) {
|
||||
okBody := `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
apiBase string
|
||||
wantAgent string
|
||||
}{
|
||||
{
|
||||
name: "sets KimiCLI User-Agent for api.kimi.com",
|
||||
apiBase: "https://api.kimi.com/coding/v1",
|
||||
wantAgent: "KimiCLI/0.77",
|
||||
},
|
||||
{
|
||||
name: "does not set KimiCLI User-Agent for other hosts",
|
||||
apiBase: "https://api.example.com/v1",
|
||||
wantAgent: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotUserAgent string
|
||||
|
||||
p := NewProvider("key", tt.apiBase, "")
|
||||
p.httpClient.Transport = roundTripFunc(
|
||||
func(r *http.Request) (*http.Response, error) {
|
||||
gotUserAgent = r.Header.Get("User-Agent")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(
|
||||
strings.NewReader(okBody),
|
||||
),
|
||||
Header: http.Header{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
|
||||
_, err := p.Chat(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hi"}},
|
||||
nil,
|
||||
"kimi-k2.5",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
if tt.wantAgent != "" {
|
||||
if gotUserAgent != tt.wantAgent {
|
||||
t.Fatalf(
|
||||
"User-Agent = %q, want %q",
|
||||
gotUserAgent, tt.wantAgent,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if gotUserAgent == "KimiCLI/0.77" {
|
||||
t.Fatalf(
|
||||
"User-Agent should not be KimiCLI/0.77 for non-kimi host",
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeMessages_PlainText(t *testing.T) {
|
||||
messages := []protocoltypes.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
|
||||
Reference in New Issue
Block a user