Merge remote-tracking branch 'origin/feat/kimi-opencode-providers' into deploy/pi-integration

This commit is contained in:
I Putu Eddy Irawan
2026-03-03 22:28:54 +07:00
8 changed files with 204 additions and 5 deletions
+3 -1
View File
@@ -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
View File
@@ -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
+60
View File
@@ -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{
+27 -1
View File
@@ -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/") ||
+3 -1
View File
@@ -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 ""
}
+1
View File
@@ -112,6 +112,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"vllm", "vllm"},
{"deepseek", "deepseek"},
{"ollama", "ollama"},
{"opencode", "opencode"},
}
for _, tt := range tests {
+15 -1
View File
@@ -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"},