Merge remote-tracking branch 'origin/feat/kimi-opencode-providers' into deploy/pi-integration
This commit is contained in:
commit
c5d2298490
8 changed files with 204 additions and 5 deletions
|
|
@ -428,6 +428,7 @@ type ProvidersConfig struct {
|
||||||
Antigravity ProviderConfig `json:"antigravity"`
|
Antigravity ProviderConfig `json:"antigravity"`
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
Mistral ProviderConfig `json:"mistral"`
|
Mistral ProviderConfig `json:"mistral"`
|
||||||
|
Opencode ProviderConfig `json:"opencode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
// 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.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
p.Qwen.APIKey == "" && p.Qwen.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
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
providerNames: []string{"moonshot", "kimi"},
|
providerNames: []string{"moonshot", "kimi", "kimi-code"},
|
||||||
protocol: "moonshot",
|
protocol: "moonshot",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
|
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
|
||||||
|
|
@ -373,6 +373,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}, true
|
}, 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
|
// Process each provider migration
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
||||||
Qwen: ProviderConfig{APIKey: "key17"},
|
Qwen: ProviderConfig{APIKey: "key17"},
|
||||||
Mistral: ProviderConfig{APIKey: "key18"},
|
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
|
// Test for legacy config with protocol prefix in model name
|
||||||
func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {
|
func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,28 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
sel.apiBase = "https://api.mistral.ai/v1"
|
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":
|
case "github_copilot", "copilot":
|
||||||
sel.providerType = providerTypeGitHubCopilot
|
sel.providerType = providerTypeGitHubCopilot
|
||||||
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
||||||
|
|
@ -210,7 +232,11 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
sel.apiBase = cfg.Providers.Moonshot.APIBase
|
sel.apiBase = cfg.Providers.Moonshot.APIBase
|
||||||
sel.proxy = cfg.Providers.Moonshot.Proxy
|
sel.proxy = cfg.Providers.Moonshot.Proxy
|
||||||
if sel.apiBase == "" {
|
if sel.apiBase == "" {
|
||||||
|
if strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/") {
|
||||||
sel.apiBase = "https://api.moonshot.cn/v1"
|
sel.apiBase = "https://api.moonshot.cn/v1"
|
||||||
|
} else {
|
||||||
|
sel.apiBase = "https://api.kimi.com/coding/v1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case strings.HasPrefix(model, "openrouter/") ||
|
case strings.HasPrefix(model, "openrouter/") ||
|
||||||
strings.HasPrefix(model, "anthropic/") ||
|
strings.HasPrefix(model, "anthropic/") ||
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
|
|
||||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"volcengine", "vllm", "qwen", "mistral":
|
"volcengine", "vllm", "qwen", "mistral", "opencode":
|
||||||
// All other OpenAI-compatible HTTP providers
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
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"
|
return "http://localhost:8000/v1"
|
||||||
case "mistral":
|
case "mistral":
|
||||||
return "https://api.mistral.ai/v1"
|
return "https://api.mistral.ai/v1"
|
||||||
|
case "opencode":
|
||||||
|
return "https://opencode.ai/zen/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||||
{"vllm", "vllm"},
|
{"vllm", "vllm"},
|
||||||
{"deepseek", "deepseek"},
|
{"deepseek", "deepseek"},
|
||||||
{"ollama", "ollama"},
|
{"ollama", "ollama"},
|
||||||
|
{"opencode", "opencode"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ type Provider struct {
|
||||||
apiBase string
|
apiBase string
|
||||||
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
isKimiAPI bool // true when apiBase points to api.kimi.com
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Provider)
|
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{
|
p := &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: strings.TrimRight(apiBase, "/"),
|
apiBase: trimmedBase,
|
||||||
httpClient: client,
|
httpClient: client,
|
||||||
|
isKimiAPI: isKimi,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
|
|
@ -176,6 +184,12 @@ func (p *Provider) Chat(
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+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)
|
resp, err := p.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package openai_compat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"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) {
|
func TestSerializeMessages_PlainText(t *testing.T) {
|
||||||
messages := []protocoltypes.Message{
|
messages := []protocoltypes.Message{
|
||||||
{Role: "user", Content: "hello"},
|
{Role: "user", Content: "hello"},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue