diff --git a/im/backend/internal/app/admin_ai.go b/im/backend/internal/app/admin_ai.go index dc608e8..9f46e87 100644 --- a/im/backend/internal/app/admin_ai.go +++ b/im/backend/internal/app/admin_ai.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" + "encoding/json" "errors" "fmt" "net/http" @@ -27,18 +28,44 @@ type aiModelRequest struct { TimeoutMS *int `json:"timeoutMs"` Status *int `json:"status"` IsDefault *bool `json:"isDefault"` + // Raw provider fields merged into every call, e.g. {"thinking":{"type":"disabled"}} + // to stop a model that reasons by default from spending the budget thinking. + Extra *string `json:"extra"` } -const aiModelColumns = `id,name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default,created_at,updated_at` +// The column is JSON, so an invalid value has to be refused before it is stored +// and starts failing every call at read time instead. +func aiModelExtra(value *string, fallback any) (any, error) { + if value == nil { + return fallback, nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil, nil + } + var parsed map[string]any + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return nil, fmt.Errorf("扩展参数必须是 JSON 对象") + } + return trimmed, nil +} + +const aiModelColumns = `id,name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default,extra_json,created_at,updated_at` func (a *App) scanAIModel(scan func(dest ...any) error) (aiModel, error) { var model aiModel var cipher string + var extra sql.NullString var created, updated time.Time if err := scan(&model.ID, &model.Name, &model.Protocol, &model.BaseURL, &model.ModelName, &cipher, - &model.Temperature, &model.MaxTokens, &model.TimeoutMS, &model.Status, &model.IsDefault, &created, &updated); err != nil { + &model.Temperature, &model.MaxTokens, &model.TimeoutMS, &model.Status, &model.IsDefault, &extra, &created, &updated); err != nil { return aiModel{}, err } + if strings.TrimSpace(extra.String) != "" { + if err := json.Unmarshal([]byte(extra.String), &model.Extra); err != nil { + return model, fmt.Errorf("模型 %s 的扩展参数不是合法 JSON", model.Name) + } + } model.HasAPIKey = cipher != "" if cipher != "" { plain, err := a.decryptSecret(cipher) @@ -187,6 +214,11 @@ func (a *App) adminCreateAIModel(w http.ResponseWriter, r *http.Request) { status = *req.Status } isDefault := req.IsDefault != nil && *req.IsDefault + extra, err := aiModelExtra(req.Extra, nil) + if err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { fail(w, http.StatusInternalServerError, 50001, "保存模型失败") @@ -200,9 +232,9 @@ func (a *App) adminCreateAIModel(w http.ResponseWriter, r *http.Request) { } } result, err := tx.ExecContext(r.Context(), `INSERT INTO ai_models - (name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default) - VALUES(?,?,?,?,?,?,?,?,?,?)`, - req.Name, req.Protocol, req.BaseURL, req.ModelName, cipher, temperature, maxTokens, timeout, status, btoi(isDefault)) + (name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default,extra_json) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`, + req.Name, req.Protocol, req.BaseURL, req.ModelName, cipher, temperature, maxTokens, timeout, status, btoi(isDefault), extra) if err != nil { if isDuplicateAIModelName(err) { fail(w, http.StatusBadRequest, 20001, "模型名称已存在") @@ -270,6 +302,16 @@ func (a *App) adminUpdateAIModel(w http.ResponseWriter, r *http.Request) { if req.IsDefault != nil { isDefault = *req.IsDefault } + var existingExtra any + if len(existing.Extra) > 0 { + encoded, _ := json.Marshal(existing.Extra) + existingExtra = string(encoded) + } + extra, err := aiModelExtra(req.Extra, existingExtra) + if err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { fail(w, http.StatusInternalServerError, 50001, "保存模型失败") @@ -282,9 +324,9 @@ func (a *App) adminUpdateAIModel(w http.ResponseWriter, r *http.Request) { return } } - args := append([]any{req.Name, req.Protocol, req.BaseURL, req.ModelName, temperature, maxTokens, timeout, status, btoi(isDefault)}, cipherArgs...) + args := append([]any{req.Name, req.Protocol, req.BaseURL, req.ModelName, temperature, maxTokens, timeout, status, btoi(isDefault), extra}, cipherArgs...) args = append(args, id) - if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET name=?,protocol=?,base_url=?,model_name=?,temperature=?,max_tokens=?,timeout_ms=?,status=?,is_default=?`+cipherClause+` WHERE id=?`, args...); err != nil { + if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET name=?,protocol=?,base_url=?,model_name=?,temperature=?,max_tokens=?,timeout_ms=?,status=?,is_default=?,extra_json=?`+cipherClause+` WHERE id=?`, args...); err != nil { if isDuplicateAIModelName(err) { fail(w, http.StatusBadRequest, 20001, "模型名称已存在") return diff --git a/im/backend/internal/app/ai_providers.go b/im/backend/internal/app/ai_providers.go index cdae894..e2fb1b9 100644 --- a/im/backend/internal/app/ai_providers.go +++ b/im/backend/internal/app/ai_providers.go @@ -68,6 +68,10 @@ type aiModel struct { IsDefault bool `json:"isDefault"` APIKey string `json:"-"` HasAPIKey bool `json:"hasApiKey"` + // Provider-specific request fields, merged into the call body. This is how a + // model that thinks by default is asked not to, without a code change: + // {"thinking":{"type":"disabled"}}. + Extra map[string]any `json:"extra,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } @@ -169,6 +173,19 @@ func aiRequestMaxTokens(req aiChatRequest, model aiModel) int { return 256 } +// aiWithExtra adds the model's configured protocol fields to a request body. +// It never overwrites what the caller set: the structural fields and the budget +// this code reasons about stay under this code's control. +func aiWithExtra(model aiModel, body map[string]any) map[string]any { + for key, value := range model.Extra { + if _, taken := body[key]; taken { + continue + } + body[key] = value + } + return body +} + // One retry, with enough room for the thinking plus a short answer, and a cap // so a misconfigured model cannot bill an unbounded completion. func aiRetryMaxTokens(current int) int { @@ -242,13 +259,13 @@ func (p openAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResu } raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, "/chat/completions"), map[string]string{"Authorization": "Bearer " + p.model.APIKey}, - map[string]any{ + aiWithExtra(p.model, map[string]any{ "model": p.model.ModelName, "messages": messages, "max_tokens": aiRequestMaxTokens(req, p.model), "temperature": aiRequestTemperature(req, p.model), "stream": false, - }) + })) if err != nil { return aiChatResult{}, err } diff --git a/im/backend/internal/app/ai_providers_test.go b/im/backend/internal/app/ai_providers_test.go index 6d1bf62..bd0b891 100644 --- a/im/backend/internal/app/ai_providers_test.go +++ b/im/backend/internal/app/ai_providers_test.go @@ -320,3 +320,51 @@ func TestTruncatedAnswerIsRetriedAndKeptIfItSurvives(t *testing.T) { t.Fatalf("text = %q", result.Text) } } + +// A model that reasons by default is told not to through its stored protocol +// fields, without a code change and without a second model row. +func TestModelExtraFieldsReachTheProviderWithoutOverridingTheCall(t *testing.T) { + server, body, _ := captureAIRequest(t, `{"id":"c","choices":[{"message":{"content":"在的"},"finish_reason":"stop"}],"usage":{}}`, 200) + model := testModel("openai", server.URL) + model.Extra = map[string]any{ + "thinking": map[string]any{"type": "disabled"}, + // Structural fields and the budget stay under this code's control. + "model": "someone-elses-model", + "max_tokens": 999999, + "stream": true, + } + provider, err := newAIProvider(model, false) + if err != nil { + t.Fatal(err) + } + if _, err = provider.Chat(context.Background(), aiChatRequest{Messages: []aiChatMessage{{Role: "user", Text: "在吗"}}}); err != nil { + t.Fatal(err) + } + thinking, ok := (*body)["thinking"].(map[string]any) + if !ok || thinking["type"] != "disabled" { + t.Fatalf("thinking = %v", (*body)["thinking"]) + } + if (*body)["model"] != "demo-model" || (*body)["max_tokens"] != float64(128) || (*body)["stream"] != false { + t.Fatalf("扩展参数不得覆盖调用本身: %v", *body) + } +} + +func TestAIModelExtraIsValidatedBeforeItIsStored(t *testing.T) { + keep := "{\"thinking\":{\"type\":\"disabled\"}}" + value, err := aiModelExtra(&keep, nil) + if err != nil || value != keep { + t.Fatalf("value = %v err = %v", value, err) + } + blank := " " + if value, err = aiModelExtra(&blank, "old"); err != nil || value != nil { + t.Fatalf("留空应清除扩展参数: %v %v", value, err) + } + broken := "{not json" + if _, err = aiModelExtra(&broken, nil); err == nil { + t.Fatal("非法 JSON 必须在写库前被拒绝") + } + // Omitted entirely: the stored value survives an edit that does not mention it. + if value, err = aiModelExtra(nil, "old"); err != nil || value != "old" { + t.Fatalf("value = %v err = %v", value, err) + } +}