feat: Add Novita provider support (#1677)
* Add Novita provider support - Add 'novita' prefix to normalizeModel switch in openai_compat provider - Add Novita provider to all_supported_vendors table in README.md - Add test cases for Novita model prefix stripping Novita endpoint: https://api.novita.ai/openai Default models: deepseek/deepseek-v3.2, zai-org/glm-5, minimax/minimax-m2.5 * feat: complete Novita provider integration * chore: drop README changes from Novita PR * fix: remove duplicate function declarations in openai_compat provider The functions buildToolsList, SupportsNativeSearch, and isNativeSearchHost were declared twice, causing compilation failures in all CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: break long line in novita test to satisfy golines linter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c07f5c948f
commit
578f90855e
6 changed files with 100 additions and 28 deletions
|
|
@ -531,6 +531,7 @@ type ProvidersConfig struct {
|
||||||
Minimax ProviderConfig `json:"minimax"`
|
Minimax ProviderConfig `json:"minimax"`
|
||||||
LongCat ProviderConfig `json:"longcat"`
|
LongCat ProviderConfig `json:"longcat"`
|
||||||
ModelScope ProviderConfig `json:"modelscope"`
|
ModelScope ProviderConfig `json:"modelscope"`
|
||||||
|
Novita ProviderConfig `json:"novita"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
@ -559,7 +560,8 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||||
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
|
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
|
||||||
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
|
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
|
||||||
p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
|
p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
|
||||||
p.ModelScope.APIKey == "" && p.ModelScope.APIBase == ""
|
p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" &&
|
||||||
|
p.Novita.APIKey == "" && p.Novita.APIBase == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
@ -589,7 +591,9 @@ type OpenAIProviderConfig struct {
|
||||||
// ModelConfig represents a model-centric provider configuration.
|
// ModelConfig represents a model-centric provider configuration.
|
||||||
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
|
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
|
||||||
// The model field uses protocol prefix format: [protocol/]model-identifier
|
// The model field uses protocol prefix format: [protocol/]model-identifier
|
||||||
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
|
// Supported protocols include openai, anthropic, antigravity, claude-cli,
|
||||||
|
// codex-cli, github-copilot, and named OpenAI-compatible protocols such as
|
||||||
|
// groq, deepseek, modelscope, and novita.
|
||||||
// Default protocol is "openai" if no prefix is specified.
|
// Default protocol is "openai" if no prefix is specified.
|
||||||
type ModelConfig struct {
|
type ModelConfig struct {
|
||||||
// Required fields
|
// Required fields
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,22 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProvidersConfig_IsEmpty(t *testing.T) {
|
||||||
|
var empty ProvidersConfig
|
||||||
|
if !empty.IsEmpty() {
|
||||||
|
t.Fatal("empty ProvidersConfig should report empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
novita := ProvidersConfig{
|
||||||
|
Novita: ProviderConfig{
|
||||||
|
APIKey: "test-key",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if novita.IsEmpty() {
|
||||||
|
t.Fatal("ProvidersConfig with novita settings should not report empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentConfig_FullParse(t *testing.T) {
|
func TestAgentConfig_FullParse(t *testing.T) {
|
||||||
jsonData := `{
|
jsonData := `{
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,8 @@ func ExtractProtocol(model string) (protocol, modelID string) {
|
||||||
|
|
||||||
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||||
// It uses the protocol prefix in the Model field to determine which provider to create.
|
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||||
// Supported protocols: openai, litellm, anthropic, anthropic-messages, antigravity,
|
// Supported protocols: openai, litellm, novita, anthropic, anthropic-messages,
|
||||||
// claude-cli, codex-cli, github-copilot
|
// antigravity, claude-cli, codex-cli, github-copilot
|
||||||
// Returns the provider, the model ID (without protocol prefix), and any error.
|
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||||
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
|
|
@ -116,7 +116,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",
|
||||||
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
|
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
|
||||||
"minimax", "longcat", "modelscope":
|
"minimax", "longcat", "modelscope", "novita":
|
||||||
// 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)
|
||||||
|
|
@ -219,6 +219,8 @@ func getDefaultAPIBase(protocol string) string {
|
||||||
return "https://openrouter.ai/api/v1"
|
return "https://openrouter.ai/api/v1"
|
||||||
case "litellm":
|
case "litellm":
|
||||||
return "http://localhost:4000/v1"
|
return "http://localhost:4000/v1"
|
||||||
|
case "novita":
|
||||||
|
return "https://api.novita.ai/openai"
|
||||||
case "groq":
|
case "groq":
|
||||||
return "https://api.groq.com/openai/v1"
|
return "https://api.groq.com/openai/v1"
|
||||||
case "zhipu":
|
case "zhipu":
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{"openai", "openai"},
|
{"openai", "openai"},
|
||||||
{"groq", "groq"},
|
{"groq", "groq"},
|
||||||
|
{"novita", "novita"},
|
||||||
{"openrouter", "openrouter"},
|
{"openrouter", "openrouter"},
|
||||||
{"cerebras", "cerebras"},
|
{"cerebras", "cerebras"},
|
||||||
{"vivgrid", "vivgrid"},
|
{"vivgrid", "vivgrid"},
|
||||||
|
|
@ -222,6 +223,34 @@ func TestGetDefaultAPIBase_ModelScope(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_Novita(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-novita",
|
||||||
|
Model: "novita/deepseek/deepseek-v3.2",
|
||||||
|
APIKey: "test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
if modelID != "deepseek/deepseek-v3.2" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "deepseek/deepseek-v3.2")
|
||||||
|
}
|
||||||
|
if _, ok := provider.(*HTTPProvider); !ok {
|
||||||
|
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDefaultAPIBase_Novita(t *testing.T) {
|
||||||
|
if got := getDefaultAPIBase("novita"); got != "https://api.novita.ai/openai" {
|
||||||
|
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "novita", got, "https://api.novita.ai/openai")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
|
func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-anthropic",
|
ModelName: "test-anthropic",
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ func normalizeModel(model, apiBase string) string {
|
||||||
prefix := strings.ToLower(before)
|
prefix := strings.ToLower(before)
|
||||||
switch prefix {
|
switch prefix {
|
||||||
case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
|
case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
|
||||||
"openrouter", "zhipu", "mistral", "vivgrid", "minimax":
|
"openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita":
|
||||||
return after
|
return after
|
||||||
default:
|
default:
|
||||||
return model
|
return model
|
||||||
|
|
|
||||||
|
|
@ -432,7 +432,28 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) {
|
func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) {
|
||||||
|
var requestBody map[string]any
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{
|
||||||
|
"message": map[string]any{"content": "ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
|
|
@ -463,31 +484,25 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) {
|
||||||
input: "vivgrid/auto",
|
input: "vivgrid/auto",
|
||||||
wantModel: "auto",
|
wantModel: "auto",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "strips novita prefix deepseek model",
|
||||||
|
input: "novita/deepseek/deepseek-v3.2",
|
||||||
|
wantModel: "deepseek/deepseek-v3.2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "strips novita prefix zai model",
|
||||||
|
input: "novita/zai-org/glm-5",
|
||||||
|
wantModel: "zai-org/glm-5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "strips novita prefix minimax model",
|
||||||
|
input: "novita/minimax/minimax-m2.5",
|
||||||
|
wantModel: "minimax/minimax-m2.5",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
var requestBody map[string]any
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp := map[string]any{
|
|
||||||
"choices": []map[string]any{
|
|
||||||
{
|
|
||||||
"message": map[string]any{"content": "ok"},
|
|
||||||
"finish_reason": "stop",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
p := NewProvider("key", server.URL, "")
|
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -573,6 +588,12 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
|
||||||
if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" {
|
if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" {
|
||||||
t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto")
|
t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto")
|
||||||
}
|
}
|
||||||
|
if got := normalizeModel(
|
||||||
|
"novita/deepseek/deepseek-v3.2",
|
||||||
|
"https://api.novita.ai/openai",
|
||||||
|
); got != "deepseek/deepseek-v3.2" {
|
||||||
|
t.Fatalf("normalizeModel(novita) = %q, want %q", got, "deepseek/deepseek-v3.2")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_RequestTimeoutDefault(t *testing.T) {
|
func TestProvider_RequestTimeoutDefault(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue