diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index b174ef44e7..a31ab7de5c 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -122,6 +122,15 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe slog.Info("registered provider", "name", "zai-coding") } + if cfg.Providers.Qiniu.APIKey != "" { + base := cfg.Providers.Qiniu.APIBase + if base == "" { + base = store.QiniuDefaultAPIBase + } + registry.Register(providers.NewOpenAIProvider("qiniu", cfg.Providers.Qiniu.APIKey, base, store.QiniuDefaultModel)) + slog.Info("registered provider", "name", "qiniu") + } + // Local / self-hosted Ollama — gated on Host, no API key required. // Ollama's OpenAI-compat endpoint accepts any non-empty Bearer value. if cfg.Providers.Ollama.Host != "" { @@ -367,6 +376,12 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi base = "https://api.z.ai/api/coding/paas/v4" } registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) + case store.ProviderQiniu: + base := p.APIBase + if base == "" { + base = store.QiniuDefaultAPIBase + } + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.QiniuDefaultModel)) case store.ProviderOllamaCloud: base := p.APIBase if base == "" { diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index a93fcdd24f..4a7442ee03 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -211,6 +211,7 @@ type ProvidersConfig struct { Bailian ProviderConfig `json:"bailian"` Zai ProviderConfig `json:"zai"` ZaiCoding ProviderConfig `json:"zai_coding"` + Qiniu ProviderConfig `json:"qiniu"` // Qiniu AI Gateway (OpenAI-compatible endpoint) Ollama OllamaConfig `json:"ollama"` // local Ollama instance (no API key needed) OllamaCloud ProviderConfig `json:"ollama_cloud"` // Ollama Cloud (API key required) ClaudeCLI ClaudeCLIConfig `json:"claude_cli"` @@ -284,6 +285,8 @@ func (p *ProvidersConfig) APIBaseForType(providerType string) string { return p.Zai.APIBase case "zai_coding": return p.ZaiCoding.APIBase + case "qiniu": + return p.Qiniu.APIBase case "ollama_cloud": return p.OllamaCloud.APIBase case "novita": @@ -315,6 +318,7 @@ func (c *Config) HasAnyProvider() bool { p.Bailian.APIKey != "" || p.Zai.APIKey != "" || p.ZaiCoding.APIKey != "" || + p.Qiniu.APIKey != "" || p.Ollama.Host != "" || p.OllamaCloud.APIKey != "" || p.ClaudeCLI.CLIPath != "" || diff --git a/internal/config/config_load.go b/internal/config/config_load.go index a844e1aeaf..b76a33b99a 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -106,6 +106,9 @@ func (c *Config) applyEnvOverrides() { envStr("GOCLAW_BAILIAN_API_KEY", &c.Providers.Bailian.APIKey) envStr("GOCLAW_ZAI_API_KEY", &c.Providers.Zai.APIKey) envStr("GOCLAW_ZAI_CODING_API_KEY", &c.Providers.ZaiCoding.APIKey) + envStr("GOCLAW_QINIU_API_KEY", &c.Providers.Qiniu.APIKey) + envStr("QINIU_API_KEY", &c.Providers.Qiniu.APIKey) + envStr("GOCLAW_QINIU_API_BASE", &c.Providers.Qiniu.APIBase) envStr("GOCLAW_OLLAMA_HOST", &c.Providers.Ollama.Host) envStr("GOCLAW_OLLAMA_CLOUD_API_KEY", &c.Providers.OllamaCloud.APIKey) envStr("GOCLAW_OLLAMA_CLOUD_API_BASE", &c.Providers.OllamaCloud.APIBase) diff --git a/internal/config/config_secrets.go b/internal/config/config_secrets.go index 0add593b1d..21479f7719 100644 --- a/internal/config/config_secrets.go +++ b/internal/config/config_secrets.go @@ -36,6 +36,7 @@ func (c *Config) MaskedCopy() *Config { maskNonEmpty(&cp.Providers.Bailian.APIKey) maskNonEmpty(&cp.Providers.Zai.APIKey) maskNonEmpty(&cp.Providers.ZaiCoding.APIKey) + maskNonEmpty(&cp.Providers.Qiniu.APIKey) maskNonEmpty(&cp.Providers.OllamaCloud.APIKey) // Mask gateway token @@ -83,6 +84,7 @@ func (c *Config) StripSecrets() { c.Providers.Bailian.APIKey = "" c.Providers.Zai.APIKey = "" c.Providers.ZaiCoding.APIKey = "" + c.Providers.Qiniu.APIKey = "" c.Providers.OllamaCloud.APIKey = "" // Gateway token @@ -135,6 +137,7 @@ func (c *Config) StripMaskedSecrets() { stripIfMasked(&c.Providers.Bailian.APIKey) stripIfMasked(&c.Providers.Zai.APIKey) stripIfMasked(&c.Providers.ZaiCoding.APIKey) + stripIfMasked(&c.Providers.Qiniu.APIKey) stripIfMasked(&c.Providers.OllamaCloud.APIKey) // Gateway token diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index 1723050de8..5ff6c60237 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -67,6 +67,11 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht return } + if p.ProviderType == store.ProviderQiniu && p.APIKey == "" { + respond(withReasoningCapabilities(qiniuModels())) + return + } + // Ollama: use native /api/tags for richer metadata (parameter size, quantization, family). // ProviderOllama has no API key; ProviderOllamaCloud requires one but both use the same endpoint. if p.ProviderType == store.ProviderOllama || p.ProviderType == store.ProviderOllamaCloud { @@ -118,6 +123,10 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht if err != nil { slog.Warn("providers.models", "provider", p.Name, "error", err) + if p.ProviderType == store.ProviderQiniu { + respond(withReasoningCapabilities(qiniuModels())) + return + } // Return empty list instead of error — provider may not support /models respond([]ModelInfo{}) return diff --git a/internal/http/provider_models_catalog.go b/internal/http/provider_models_catalog.go index 5761479597..5a3aab304b 100644 --- a/internal/http/provider_models_catalog.go +++ b/internal/http/provider_models_catalog.go @@ -63,6 +63,13 @@ func dashScopeModels() []ModelInfo { } } +// qiniuModels returns a fallback list for Qiniu when /models cannot be fetched. +func qiniuModels() []ModelInfo { + return []ModelInfo{ + {ID: "deepseek-v3", Name: "DeepSeek V3"}, + } +} + // claudeCLIModels returns the model aliases accepted by the Claude CLI. func claudeCLIModels() []ModelInfo { return []ModelInfo{ diff --git a/internal/http/provider_models_test.go b/internal/http/provider_models_test.go index 265ae30fdc..c48f51f03a 100644 --- a/internal/http/provider_models_test.go +++ b/internal/http/provider_models_test.go @@ -179,6 +179,44 @@ func TestProvidersHandlerListProviderModelsOpenAICompatAnnotatesKnownModels(t *t } } +func TestProvidersHandlerListProviderModelsQiniuFallbackModel(t *testing.T) { + token := setupProvidersAdminToken(t) + providerStore := newMockProviderStore() + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: "qiniu", + ProviderType: store.ProviderQiniu, + Enabled: true, + } + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/providers/"+provider.ID.String()+"/models", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + + var result ProviderModelsResponse + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Decode() error = %v", err) + } + if len(result.Models) != 1 { + t.Fatalf("models len = %d, want 1", len(result.Models)) + } + if result.Models[0].ID != store.QiniuDefaultModel { + t.Fatalf("models[0].id = %q, want %q", result.Models[0].ID, store.QiniuDefaultModel) + } +} + // TestProvidersHandlerListProviderModelsOllamaRichMetadata verifies that the // handler fetches /api/tags from Ollama and maps rich details (family, // parameter_size, quantization_level) into the display name. diff --git a/internal/http/providers.go b/internal/http/providers.go index ff8fa17718..fa82d06aa6 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -239,6 +239,12 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { base = store.NovitaDefaultAPIBase } h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.NovitaDefaultModel)) + case store.ProviderQiniu: + base := apiBase + if base == "" { + base = store.QiniuDefaultAPIBase + } + h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.QiniuDefaultModel)) default: prov := providers.NewOpenAIProvider(p.Name, p.APIKey, apiBase, "") if p.ProviderType == store.ProviderMiniMax { diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index c61da5dc8a..c0a929e33a 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -31,6 +31,7 @@ const ( ProviderOllamaCloud = "ollama_cloud" // Ollama Cloud (Bearer token required) ProviderACP = "acp" // ACP (Agent Client Protocol) agent subprocess ProviderNovita = "novita" // Novita AI (OpenAI-compatible endpoint) + ProviderQiniu = "qiniu" // Qiniu AI Gateway (OpenAI-compatible endpoint) ProviderBytePlus = "byteplus" // BytePlus ModelArk (Seed 2.0 models) ProviderBytePlusCoding = "byteplus_coding" // BytePlus ModelArk Coding Plan @@ -42,6 +43,10 @@ const ( BytePlusDefaultAPIBase = "https://ark.ap-southeast.bytepluses.com/api/v3" BytePlusCodingDefaultAPIBase = "https://ark.ap-southeast.bytepluses.com/api/coding/v3" BytePlusDefaultModel = "seed-2-0-lite-260228" + + // Qiniu AI Gateway defaults. + QiniuDefaultAPIBase = "https://api.qnaigc.com/v1" + QiniuDefaultModel = "deepseek-v3" ) // ValidProviderTypes lists all accepted provider_type values. @@ -68,6 +73,7 @@ var ValidProviderTypes = map[string]bool{ ProviderOllamaCloud: true, ProviderACP: true, ProviderNovita: true, + ProviderQiniu: true, ProviderBytePlus: true, ProviderBytePlusCoding: true, } diff --git a/ui/desktop/frontend/src/constants/providers.ts b/ui/desktop/frontend/src/constants/providers.ts index e74cb04c60..de71833a0e 100644 --- a/ui/desktop/frontend/src/constants/providers.ts +++ b/ui/desktop/frontend/src/constants/providers.ts @@ -23,6 +23,7 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: 'yescale', label: 'YesScale', apiBase: 'https://api.yescale.one/v1', needsKey: true }, { value: 'zai', label: 'Z.ai API', apiBase: 'https://api.z.ai/api/paas/v4', needsKey: true }, { value: 'zai_coding', label: 'Z.ai Coding Plan', apiBase: 'https://api.z.ai/api/coding/paas/v4', needsKey: true }, + { value: 'qiniu', label: 'Qiniu', apiBase: 'https://api.qnaigc.com/v1', needsKey: true }, { value: 'byteplus', label: 'BytePlus ModelArk', apiBase: 'https://ark.ap-southeast.bytepluses.com/api/v3', needsKey: true }, { value: 'byteplus_coding', label: 'BytePlus Coding Plan', apiBase: 'https://ark.ap-southeast.bytepluses.com/api/coding/v3', needsKey: true }, { value: 'ollama', label: 'Ollama (Local)', apiBase: 'http://localhost:11434/v1', needsKey: false }, diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index 34be530932..5d693e115e 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -30,6 +30,7 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: "yescale", label: "YesScale", apiBase: "https://api.yescale.one/v1", placeholder: "" }, { value: "zai", label: "Z.ai API", apiBase: "https://api.z.ai/api/paas/v4", placeholder: "" }, { value: "zai_coding", label: "Z.ai Coding Plan", apiBase: "https://api.z.ai/api/coding/paas/v4", placeholder: "" }, + { value: "qiniu", label: "Qiniu", apiBase: "https://api.qnaigc.com/v1", placeholder: "" }, { value: "byteplus", label: "BytePlus ModelArk", apiBase: "https://ark.ap-southeast.bytepluses.com/api/v3", placeholder: "" }, { value: "byteplus_coding", label: "BytePlus Coding Plan", apiBase: "https://ark.ap-southeast.bytepluses.com/api/coding/v3", placeholder: "" }, { value: "ollama", label: "Ollama (Local)", apiBase: "http://localhost:11434/v1", placeholder: "" },