Go: fix xunfei model (#20757)

Signed-off-by: Jin Hai <[email protected]>
This commit is contained in:
Jin Hai
2026-10-11 08:53:26 +08:00
committed by GitHub
parent d542485a5e
commit bc2934b89d
5 changed files with 13 additions and 173 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "XunFei Spark",
"url": {
"default": "https://spark-api-open.xf-yun.com"
"default": "https://maas-api.cn-huabei-1.xf-yun.com"
},
"url_suffix": {
"chat": "v2/chat/completions"
@@ -9,7 +9,7 @@
"class": "xunfei",
"models": [
{
"name": "spark-x",
"name": "spark-x2.5-1.7b",
"context_length": 128000,
"max_output": 8192,
"model_types": [
+4 -44
View File
@@ -22,48 +22,8 @@ import (
"fmt"
"io"
"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
}
@@ -77,7 +37,7 @@ func NewXunFeiModel(baseURL map[string]string, urlSuffix URLSuffix) *XunFeiModel
// The Spark HTTP API authenticates with the credential bundle's
// spark_api_password, not the raw stored key.
authHeader: func(cfg *APIConfig) (string, string) {
return "Authorization", "Bearer " + resolveBearerToken(cfg)
return "Authorization", "Bearer " + *cfg.ApiKey
},
},
}
@@ -105,7 +65,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, resolveSparkModel(modelName), messages, false)
reqBody := buildRequestBody(chatModelConfig, modelName, messages, false)
if chatModelConfig != nil {
if chatModelConfig.Thinking != nil {
@@ -144,7 +104,7 @@ func (x *XunFeiModel) ChatStreamlyWithSender(ctx context.Context, modelName stri
}
url := fmt.Sprintf("%s/%s", resolvedBaseURL, x.baseModel.URLSuffix.Chat)
reqBody := buildRequestBody(modelConfig, resolveSparkModel(modelName), messages, true)
reqBody := buildRequestBody(modelConfig, modelName, messages, true)
if modelConfig != nil {
if modelConfig.Thinking != nil {
@@ -242,7 +202,7 @@ func (x *XunFeiModel) CheckConnection(ctx context.Context, apiConfig *APIConfig)
// free Spark-Lite model.
maxTokens := 1
chatConfig := &ChatConfig{MaxTokens: &maxTokens}
_, err := x.ChatWithMessages(ctx, "Spark-Lite", []Message{{Role: "user", Content: "Hi"}}, apiConfig, chatConfig, nil)
_, err := x.ChatWithMessages(ctx, "spark-x2.5-1.7b", []Message{{Role: "user", Content: "Hi"}}, apiConfig, chatConfig, nil)
return err
}
-96
View File
@@ -1,7 +1,6 @@
package models
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@@ -9,47 +8,6 @@ import (
"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"})
@@ -131,60 +89,6 @@ func newXunFeiForTest(baseURL string) *XunFeiModel {
)
}
func TestXunFeiChatUsesResolvedBearerToken(t *testing.T) {
withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method=%s, want POST", r.Method)
}
// The Spark credential bundle is stored as JSON; the request must
// authenticate with the extracted spark_api_password.
if got := r.Header.Get("Authorization"); got != "Bearer pwd" {
t.Errorf("Authorization=%q, want Bearer pwd", got)
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
return
}
if body["model"] != "lite" {
t.Errorf("model=%v, want lite (resolved Spark-Lite)", body["model"])
}
if body["stream"] != false {
t.Errorf("stream=%v, want false", body["stream"])
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"choices": []map[string]interface{}{{
"message": map[string]interface{}{
"content": "pong",
"reasoning_content": "\nthought",
},
}},
})
}))
defer srv.Close()
bundle := `{"spark_api_password":"pwd","spark_app_id":"app","spark_api_secret":"secret","spark_api_key":"key"}`
resp, err := newXunFeiForTest(srv.URL).ChatWithMessages(
ctx,
"Spark-Lite",
[]Message{{Role: "user", Content: "ping"}},
&APIConfig{ApiKey: &bundle},
nil,
nil,
)
if err != nil {
t.Fatalf("ChatWithMessages: %v", err)
}
if resp.Answer == nil || *resp.Answer != "pong" {
t.Errorf("Answer=%v, want pong", resp.Answer)
}
if resp.ReasonContent == nil || *resp.ReasonContent != "thought" {
t.Errorf("ReasonContent=%v, want thought", resp.ReasonContent)
}
}
func TestXunFeiStreamHappyPath(t *testing.T) {
withSSRFBypass(t)
ctx := t.Context()
+6 -28
View File
@@ -17,7 +17,6 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -413,7 +412,7 @@ func (h *ProviderHandler) ShowModel(c *gin.Context) {
type CreateProviderInstanceRequest struct {
InstanceName string `json:"instance_name" binding:"required"`
APIKey json.RawMessage `json:"api_key"`
APIKey string `json:"api_key"`
BaseURL string `json:"base_url"`
Region string `json:"region"`
ModelInfo []service.CreateInstanceModelInfo `json:"model_info"`
@@ -435,26 +434,6 @@ func validateInstanceName(instanceName string) error {
return nil
}
// 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 == "" {
@@ -475,12 +454,11 @@ 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 apiKey == "" && req.BaseURL == "" && req.Region == "" && len(req.ModelInfo) == 0 {
if req.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())
@@ -490,7 +468,7 @@ func (h *ProviderHandler) CreateProviderInstance(c *gin.Context) {
return
}
code, err := h.modelProviderService.CreateProviderInstance(ctx, providerName, req.InstanceName, apiKey, req.BaseURL, req.Region, userID, req.ModelInfo)
code, err := h.modelProviderService.CreateProviderInstance(ctx, providerName, req.InstanceName, req.APIKey, req.BaseURL, req.Region, userID, req.ModelInfo)
if err != nil {
common.ErrorWithCode(c, code, err.Error())
return
@@ -584,7 +562,7 @@ func (h *ProviderHandler) CheckConnection(c *gin.Context) {
}
userID := c.GetString("user_id")
errCode, err := h.modelProviderService.CheckConnection(ctx, providerName, normalizeAPIKey(req.APIKey), req.Region, req.BaseURL, req.InstanceID, userID, req.ModelInfo)
errCode, err := h.modelProviderService.CheckConnection(ctx, providerName, req.APIKey, req.Region, req.BaseURL, req.InstanceID, userID, req.ModelInfo)
if err != nil {
common.ErrorWithCode(c, errCode, err.Error())
return
@@ -688,7 +666,7 @@ func (h *ProviderHandler) ShowTask(c *gin.Context) {
type AlterProviderInstanceRequest struct {
InstanceName string `json:"instance_name"`
APIKey json.RawMessage `json:"api_key"`
APIKey string `json:"api_key"`
BaseURL string `json:"base_url"`
Region string `json:"region"`
ModelInfo *[]service.CreateInstanceModelInfo `json:"model_info" binding:"required"`
@@ -720,7 +698,7 @@ func (h *ProviderHandler) AlterProviderInstance(c *gin.Context) {
return
}
code, err := h.modelProviderService.AlterProviderInstance(ctx, userID, providerName, instanceName, req.InstanceName, normalizeAPIKey(req.APIKey), req.BaseURL, req.Region, *req.ModelInfo)
code, err := h.modelProviderService.AlterProviderInstance(ctx, userID, providerName, instanceName, req.InstanceName, req.APIKey, req.BaseURL, req.Region, *req.ModelInfo)
if err != nil {
common.ErrorWithCode(c, code, err.Error())
return
+1 -3
View File
@@ -90,9 +90,7 @@ type CheckConnectionModelInfo struct {
}
type CheckConnectionRequest struct {
// 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"`
APIKey string `json:"api_key"`
Region string `json:"region"`
BaseURL string `json:"base_url"`
InstanceID string `json:"instance_id"`