fix: XunFei Spark provider API key verification and model listing (#17652)

This commit is contained in:
euvre
2026-08-03 11:25:25 +08:00
committed by GitHub
parent 4e78f1f440
commit 8353fc7855
4 changed files with 147 additions and 17 deletions

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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"`