From 8353fc7855b774c41e5c4e250a7abf196164a800 Mon Sep 17 00:00:00 2001 From: euvre <93761161+euvre@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:25:25 +0800 Subject: [PATCH] fix: XunFei Spark provider API key verification and model listing (#17652) --- internal/entity/models/xunfei.go | 65 ++++++++++++++++++++++++--- internal/entity/models/xunfei_test.go | 60 +++++++++++++++++++++++-- internal/handler/providers.go | 35 ++++++++++++--- internal/service/model_service.go | 4 +- 4 files changed, 147 insertions(+), 17 deletions(-) diff --git a/internal/entity/models/xunfei.go b/internal/entity/models/xunfei.go index b27ac957d4..52ee0cd4b5 100644 --- a/internal/entity/models/xunfei.go +++ b/internal/entity/models/xunfei.go @@ -24,8 +24,48 @@ import ( "io" "net/http" "ragflow/internal/common" + "strings" ) +// sparkModelVersions maps the catalog model names to the version identifiers +// the XunFei Spark HTTP API expects in the request body's "model" field. +// Mirrors SparkChat.model2version in rag/llm/chat_model.py. +var sparkModelVersions = map[string]string{ + "Spark-Max": "generalv3.5", + "Spark-Max-32K": "max-32k", + "Spark-Lite": "lite", + "Spark-Pro": "generalv3", + "Spark-Pro-128K": "pro-128k", + "Spark-4.0-Ultra": "4.0Ultra", +} + +func resolveSparkModel(modelName string) string { + if version, ok := sparkModelVersions[modelName]; ok { + return version + } + return modelName +} + +// resolveBearerToken extracts the credential used as the Bearer token. The +// instance stores the XunFei credential bundle (API password, APPID, API +// secret, API key) as a JSON object string; the Spark HTTP API authenticates +// with the bundle's spark_api_password. +func resolveBearerToken(apiConfig *APIConfig) string { + if apiConfig == nil || apiConfig.ApiKey == nil { + return "" + } + key := strings.TrimSpace(*apiConfig.ApiKey) + if strings.HasPrefix(key, "{") { + var bundle map[string]interface{} + if err := json.Unmarshal([]byte(key), &bundle); err == nil { + if password, ok := bundle["spark_api_password"].(string); ok && password != "" { + return password + } + } + } + return key +} + type XunFeiModel struct { baseModel BaseModel } @@ -62,7 +102,7 @@ func (x *XunFeiModel) ChatWithMessages(ctx context.Context, modelName string, me return nil, err } url := fmt.Sprintf("%s/%s", resolvedBaseURL, x.baseModel.URLSuffix.Chat) - reqBody := buildRequestBody(chatModelConfig, modelName, messages, false) + reqBody := buildRequestBody(chatModelConfig, resolveSparkModel(modelName), messages, false) if chatModelConfig != nil { if chatModelConfig.Thinking != nil { @@ -92,7 +132,7 @@ func (x *XunFeiModel) ChatWithMessages(ctx context.Context, modelName string, me } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", resolveBearerToken(apiConfig))) resp, err := x.baseModel.httpClient.Do(req) if err != nil { @@ -105,6 +145,10 @@ func (x *XunFeiModel) ChatWithMessages(ctx context.Context, modelName string, me return nil, fmt.Errorf("failed to read response body: %w", err) } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + // Parse Response var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { @@ -167,7 +211,7 @@ func (x *XunFeiModel) ChatStreamlyWithSender(ctx context.Context, modelName stri } url := fmt.Sprintf("%s/%s", resolvedBaseURL, x.baseModel.URLSuffix.Chat) - reqBody := buildRequestBody(modelConfig, modelName, messages, true) + reqBody := buildRequestBody(modelConfig, resolveSparkModel(modelName), messages, true) if modelConfig != nil { if modelConfig.Thinking != nil { @@ -197,7 +241,7 @@ func (x *XunFeiModel) ChatStreamlyWithSender(ctx context.Context, modelName stri } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", resolveBearerToken(apiConfig))) resp, err := x.baseModel.httpClient.Do(req) if err != nil { @@ -323,7 +367,7 @@ func (x *XunFeiModel) ListModels(ctx context.Context, apiConfig *APIConfig) ([]L } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", resolveBearerToken(apiConfig))) resp, err := x.baseModel.httpClient.Do(req) if err != nil { @@ -358,7 +402,16 @@ func (x *XunFeiModel) Balance(ctx context.Context, apiConfig *APIConfig) (map[st } func (x *XunFeiModel) CheckConnection(ctx context.Context, apiConfig *APIConfig) error { - return fmt.Errorf("%s, no such method", x.Name()) + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + + // Verify the credential bundle with a minimal chat request against the + // free Spark-Lite model. + maxTokens := 1 + chatConfig := &ChatConfig{MaxTokens: &maxTokens} + _, err := x.ChatWithMessages(ctx, "Spark-Lite", []Message{{Role: "user", Content: "Hi"}}, apiConfig, chatConfig, nil) + return err } func (x *XunFeiModel) ListTasks(ctx context.Context, apiConfig *APIConfig) ([]ListTaskStatus, error) { diff --git a/internal/entity/models/xunfei_test.go b/internal/entity/models/xunfei_test.go index b09bfa6a98..f3969a6da3 100644 --- a/internal/entity/models/xunfei_test.go +++ b/internal/entity/models/xunfei_test.go @@ -1,6 +1,61 @@ package models -import "testing" +import ( + "strings" + "testing" +) + +func TestResolveSparkModel(t *testing.T) { + cases := map[string]string{ + "Spark-Max": "generalv3.5", + "Spark-Max-32K": "max-32k", + "Spark-Lite": "lite", + "Spark-Pro": "generalv3", + "Spark-Pro-128K": "pro-128k", + "Spark-4.0-Ultra": "4.0Ultra", + // Unknown names pass through unchanged (e.g. "spark-x"). + "spark-x": "spark-x", + } + for name, want := range cases { + if got := resolveSparkModel(name); got != want { + t.Errorf("resolveSparkModel(%q) = %q, want %q", name, got, want) + } + } +} + +func TestResolveBearerToken(t *testing.T) { + bundle := `{"spark_api_password":"pwd","spark_app_id":"app","spark_api_secret":"secret","spark_api_key":"key"}` + cases := []struct { + name string + key *string + want string + }{ + {"nil key", nil, ""}, + {"plain key", strPtr("sk-plain"), "sk-plain"}, + {"bundle uses password", strPtr(bundle), "pwd"}, + {"bundle without password falls back to raw", strPtr(`{"spark_app_id":"app"}`), `{"spark_app_id":"app"}`}, + {"malformed json falls back to raw", strPtr(`{not-json`), `{not-json`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := resolveBearerToken(&APIConfig{ApiKey: tc.key}) + if got != tc.want { + t.Errorf("resolveBearerToken() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestXunFeiCheckConnectionRequiresAPIKey(t *testing.T) { + driver := NewXunFeiModel(map[string]string{"default": "http://unused"}, URLSuffix{}). + NewInstance(map[string]string{"default": "http://unused"}) + err := driver.CheckConnection(t.Context(), &APIConfig{}) + if err == nil || !strings.Contains(err.Error(), "api key is required") { + t.Errorf("CheckConnection with empty key = %v, want 'api key is required'", err) + } +} + +func strPtr(s string) *string { return &s } func TestXunFeiUnsupportedMethodsReturnNoSuchMethod(t *testing.T) { withSSRFBypass(t) @@ -48,9 +103,6 @@ func TestXunFeiUnsupportedMethodsReturnNoSuchMethod(t *testing.T) { _, err := driver.Balance(ctx, &APIConfig{}) return err }}, - {"CheckConnection", func() error { - return driver.CheckConnection(ctx, &APIConfig{}) - }}, {"ListTasks", func() error { _, err := driver.ListTasks(ctx, &APIConfig{}) return err diff --git a/internal/handler/providers.go b/internal/handler/providers.go index 47d479cd43..50f578d9c9 100644 --- a/internal/handler/providers.go +++ b/internal/handler/providers.go @@ -17,6 +17,8 @@ package handler import ( + "bytes" + "encoding/json" "errors" "fmt" "net/http" @@ -242,12 +244,32 @@ func (h *ProviderHandler) ShowModel(c *gin.Context) { type CreateProviderInstanceRequest struct { InstanceName string `json:"instance_name" binding:"required"` - APIKey string `json:"api_key"` + APIKey json.RawMessage `json:"api_key"` BaseURL string `json:"base_url"` Region string `json:"region"` ModelInfo []service.CreateInstanceModelInfo `json:"model_info"` } +// normalizeAPIKey accepts api_key as either a JSON string or a JSON object +// (credential bundles such as XunFei Spark's +// {"spark_api_password": ..., "spark_app_id": ..., ...}) and normalizes it to +// the string form persisted on the instance. +func normalizeAPIKey(raw json.RawMessage) string { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed == "null" { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + return trimmed + } + return buf.String() +} + func (h *ProviderHandler) CreateProviderInstance(c *gin.Context) { providerName := c.Param("provider_id_or_name") if providerName == "" { @@ -263,11 +285,12 @@ func (h *ProviderHandler) CreateProviderInstance(c *gin.Context) { } userID := c.GetString("user_id") + apiKey := normalizeAPIKey(req.APIKey) // If the request body only contains "instance_name", create a name-only // instance without API key validation or model creation. // Mirrors Python's provider_api.py:349 — set(data.keys()) == {"instance_name"}. - if req.APIKey == "" && req.BaseURL == "" && req.Region == "" && len(req.ModelInfo) == 0 { + if apiKey == "" && req.BaseURL == "" && req.Region == "" && len(req.ModelInfo) == 0 { code, err := h.modelProviderService.CreateNameOnlyProviderInstance(ctx, providerName, req.InstanceName, userID) if err != nil { common.ErrorWithCode(c, code, err.Error()) @@ -277,7 +300,7 @@ func (h *ProviderHandler) CreateProviderInstance(c *gin.Context) { return } - _, err := h.modelProviderService.CreateProviderInstance(ctx, providerName, req.InstanceName, req.APIKey, req.BaseURL, req.Region, userID, req.ModelInfo) + _, err := h.modelProviderService.CreateProviderInstance(ctx, providerName, req.InstanceName, apiKey, req.BaseURL, req.Region, userID, req.ModelInfo) if err != nil { common.ErrorWithCode(c, common.CodeServerError, err.Error()) return @@ -371,7 +394,7 @@ func (h *ProviderHandler) CheckConnection(c *gin.Context) { } userID := c.GetString("user_id") - errCode, err := h.modelProviderService.CheckConnection(ctx, providerName, req.APIKey, req.Region, req.BaseURL, req.InstanceID, userID, req.ModelInfo) + errCode, err := h.modelProviderService.CheckConnection(ctx, providerName, normalizeAPIKey(req.APIKey), req.Region, req.BaseURL, req.InstanceID, userID, req.ModelInfo) if err != nil { common.ErrorWithCode(c, errCode, err.Error()) return @@ -475,7 +498,7 @@ func (h *ProviderHandler) ShowTask(c *gin.Context) { type AlterProviderInstanceRequest struct { InstanceName string `json:"instance_name"` - APIKey string `json:"api_key"` + APIKey json.RawMessage `json:"api_key"` BaseURL string `json:"base_url"` Region string `json:"region"` ModelInfo []service.CreateInstanceModelInfo `json:"model_info"` @@ -513,7 +536,7 @@ func (h *ProviderHandler) AlterProviderInstance(c *gin.Context) { verify = *req.Verify } - code, err := h.modelProviderService.AlterProviderInstance(ctx, userID, providerName, instanceName, req.InstanceName, req.APIKey, req.BaseURL, req.Region, req.ModelInfo, verify) + code, err := h.modelProviderService.AlterProviderInstance(ctx, userID, providerName, instanceName, req.InstanceName, normalizeAPIKey(req.APIKey), req.BaseURL, req.Region, req.ModelInfo, verify) if err != nil { common.ErrorWithCode(c, code, err.Error()) return diff --git a/internal/service/model_service.go b/internal/service/model_service.go index b7ea0954ce..e420d157d2 100644 --- a/internal/service/model_service.go +++ b/internal/service/model_service.go @@ -159,7 +159,9 @@ type CheckConnectionModelInfo struct { } type CheckConnectionRequest struct { - APIKey string `json:"api_key"` + // APIKey may be a JSON string or a JSON object (credential bundles such + // as XunFei Spark's); the handler normalizes it to a string before use. + APIKey json.RawMessage `json:"api_key"` Region string `json:"region"` BaseURL string `json:"base_url"` InstanceID string `json:"instance_id"`