模型支持协议扩展参数,用于关闭思考模式
DeepSeek 现役的 v4 系列全部默认开启思考模式,没有单独的非思考型模型
可换;官方给的关闭方式是在请求里带 thinking={"type":"disabled"}。
ai_models 表早有 extra_json 字段但从未接线,这里接通:模型配置里的
JSON 对象会合并进每次调用的请求体,因此任何协议级参数都能在管理端
配置,不必改代码。合并不会覆盖 model/messages/max_tokens/temperature/
stream —— 预算和结构由代码掌握,重试逻辑才不会被配置绕过。
写入前校验 JSON,避免非法值存进 JSON 列后每次调用才报错。
线上模型已配置为关闭思考:同一会话下由 10133 ms / 1024 输出 tokens、
回复被截断,变为 669 ms / 10 tokens、回复完整。max_tokens 相应回调至 512。
管理端模型表单新增「协议参数」输入框(该目录尚未纳入版本库,仅已发布)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
812d8032e1
commit
151834caac
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user