feat: «Думать» uses a model's own thinking when it has it (OpenAI-compatible reasoning, Anthropic extended thinking) instead of asking it to think in words too; learned per model, the word-thinking prompt kept for models without; without «Думать» such a model is asked not to think

This commit is contained in:
redtea committed 2026-10-17 15:36:35 +03:00
1 parent 37ef886503
commit 03f963340e
2 files changed
+311 -12

No files matched your search

+167 -12
View File
@@ -39,6 +39,10 @@ const (
DefaultAIURL = "https://agentproxy.minomix.ru"
)
// aiNativeKey is the config key of what was learned of a model: "1" it
// thinks by itself, "0" it does not, missing - not known yet.
func aiNativeKey(id string) string { return "ai_native_" + id }
// aiUserKey is the config key of a user's own switch. Missing means on by
// default; an explicit "0" means the user turned it off.
func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) }
@@ -54,6 +58,10 @@ type aiConfig struct {
URL string `json:"url,omitempty"`
Key string `json:"key,omitempty"`
Model string `json:"model,omitempty"`
// think, for one request: "on" asks for the model's own thinking, "off"
// asks it not to think, "" leaves it to the model.
think string
}
// label is the model's name in the chat: its own, else its model or kind.
@@ -168,6 +176,59 @@ func sseData(r io.Reader, fn func(data string) (stop bool, err error)) error {
return sc.Err()
}
// aiHTTPError is a model's answer other than 2xx.
type aiHTTPError struct {
Code int
Msg string
}
func (e aiHTTPError) Error() string {
return fmt.Sprintf("модель ответила %d: %s", e.Code, e.Msg)
}
// badRequest: the model did not take the request as it was (an option it
// does not know, say).
func badRequest(err error) bool {
var e aiHTTPError
return errors.As(err, &e) && (e.Code == http.StatusBadRequest || e.Code == http.StatusUnprocessableEntity)
}
// thinkOpen and thinkClose frame a model's own thinking in the answer, as
// the chat shows thinking (and as a model asked to think in words writes it).
const (
thinkOpen = "::думаю::\n"
thinkClose = "\n::ответ::\n"
)
// thinkWrap passes on a model's thinking and answer as they come, the
// thinking framed by thinkOpen and thinkClose.
type thinkWrap struct {
emit func(string)
state int // 0: nothing yet, 1: thinking, 2: answering
}
func (w *thinkWrap) think(t string) {
if t == "" || w.state == 2 {
return
}
if w.state == 0 {
w.emit(thinkOpen)
w.state = 1
}
w.emit(t)
}
func (w *thinkWrap) text(t string) {
if t == "" {
return
}
if w.state == 1 {
w.emit(thinkClose)
}
w.state = 2
w.emit(t)
}
func aiPost(ctx context.Context, url string, body any, hdr map[string]string) (*http.Response, error) {
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
@@ -185,7 +246,7 @@ func aiPost(ctx context.Context, url string, body any, hdr map[string]string) (*
if resp.StatusCode/100 != 2 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
resp.Body.Close()
return nil, fmt.Errorf("модель ответила %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
return nil, aiHTTPError{resp.StatusCode, strings.TrimSpace(string(msg))}
}
return resp, nil
}
@@ -226,10 +287,21 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
if cfg.Key != "" {
hdr["Authorization"] = "Bearer " + cfg.Key
}
resp, err := aiPost(ctx, cfg.URL+"/chat/completions", map[string]any{"model": cfg.Model, "stream": true, "messages": apiMessages}, hdr)
// Thinking on or off by the chat template's switch (llama.cpp, vLLM,
// Ollama…); a server that does not know it is asked again without.
body := map[string]any{"model": cfg.Model, "stream": true, "messages": apiMessages}
if cfg.think != "" {
body["chat_template_kwargs"] = map[string]any{"enable_thinking": cfg.think == "on"}
}
resp, err := aiPost(ctx, cfg.URL+"/chat/completions", body, hdr)
if err != nil && cfg.think != "" && badRequest(err) {
delete(body, "chat_template_kwargs")
resp, err = aiPost(ctx, cfg.URL+"/chat/completions", body, hdr)
}
if err != nil {
return err
}
tw := &thinkWrap{emit: emit}
defer resp.Body.Close()
return sseData(resp.Body, func(d string) (bool, error) {
if d == "[DONE]" {
@@ -238,7 +310,9 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
var m struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
} `json:"delta"`
} `json:"choices"`
Error *struct {
@@ -252,9 +326,8 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
return true, errors.New(m.Error.Message)
}
for _, c := range m.Choices {
if c.Delta.Content != "" {
emit(c.Delta.Content)
}
tw.think(c.Delta.ReasoningContent + c.Delta.Reasoning)
tw.text(c.Delta.Content)
}
return false, nil
})
@@ -280,17 +353,32 @@ func aiAnthropic(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg,
}
apiMessages = append(apiMessages, map[string]any{"role": m.Role, "content": content})
}
resp, err := aiPost(ctx, cfg.URL+"/v1/messages", map[string]any{"model": model, "max_tokens": 4096, "system": system, "messages": apiMessages, "stream": true},
map[string]string{"x-api-key": cfg.Key, "anthropic-version": "2023-06-01"})
body := map[string]any{"model": model, "max_tokens": 4096, "system": system, "messages": apiMessages, "stream": true}
hdr := map[string]string{"x-api-key": cfg.Key, "anthropic-version": "2023-06-01"}
// Extended thinking, for «Думать»; a model without it is asked again
// as it was.
if cfg.think == "on" {
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 4096}
body["max_tokens"] = 12288
}
resp, err := aiPost(ctx, cfg.URL+"/v1/messages", body, hdr)
if err != nil && cfg.think == "on" && badRequest(err) {
delete(body, "thinking")
body["max_tokens"] = 4096
resp, err = aiPost(ctx, cfg.URL+"/v1/messages", body, hdr)
}
if err != nil {
return err
}
tw := &thinkWrap{emit: emit}
defer resp.Body.Close()
return sseData(resp.Body, func(d string) (bool, error) {
var m struct {
Type string `json:"type"`
Delta struct {
Text string `json:"text"`
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
} `json:"delta"`
Error struct {
Message string `json:"message"`
@@ -301,7 +389,11 @@ func aiAnthropic(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg,
}
switch m.Type {
case "content_block_delta":
emit(m.Delta.Text)
if m.Delta.Type == "thinking_delta" {
tw.think(m.Delta.Thinking)
} else {
tw.text(m.Delta.Text)
}
case "message_stop":
return true, nil
case "error":
@@ -807,13 +899,35 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
if in.Search {
system += searchPrompt(s.searchConf())
}
if in.Think {
// «Думать»: a model with thinking of its own (OpenAI-compatible or
// Anthropic) thinks by itself, once; the others are asked to think in
// words. Which it is, is learned from its answers and kept. Without
// «Думать» a model that would think anyway is asked not to.
native := ""
if cfg.Type == "openai" || cfg.Type == "anthropic" {
native, _ = s.db.GetConfig(aiNativeKey(cfg.ID))
}
ownThink := in.Think && (cfg.Type == "openai" || cfg.Type == "anthropic") && native != "0"
promptThink := func() {
system += aiThink
// Models heed the last words most: the reminder goes there too (not
// kept with the talk).
if n := len(msgs); n > 0 && msgs[n-1].Role == "user" {
msgs[n-1].Content += "\n\n(Сначала подумай: первая строка ответа - ::думаю::, дальше рассуждение, потом строка ::ответ:: и сам ответ.)"
}
if cfg.Type == "openai" {
cfg.think = "off"
} else {
cfg.think = ""
}
}
switch {
case ownThink:
cfg.think = "on"
case in.Think:
promptThink()
case cfg.Type == "openai":
cfg.think = "off"
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
@@ -836,7 +950,40 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
fl.Flush()
}
}
err := aiStream(r.Context(), cfg, system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit)
run := func(ctx context.Context, emit func(string)) error {
return aiStream(ctx, cfg, system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit)
}
var err error
if ownThink && native == "" {
// Not known yet: its first words tell. Thinking - it has its own;
// an answer straight away - it has not, and that answer is dropped
// unseen for one asked to think in words.
ctx, cancel := context.WithCancel(r.Context())
first, dropped := true, false
err = run(ctx, func(t string) {
if dropped || t == "" {
return
}
if first {
first = false
if t != thinkOpen {
dropped = true
cancel()
return
}
_ = s.db.SetConfig(aiNativeKey(cfg.ID), "1")
}
emit(t)
})
cancel()
if dropped {
_ = s.db.SetConfig(aiNativeKey(cfg.ID), "0")
promptThink()
err = run(r.Context(), emit)
}
} else {
err = run(r.Context(), emit)
}
// The answer is kept whole, thinking too (to show it again); the model
// gets it without (aiWindow).
ans := aiMsg{Role: "assistant", Content: answer.String()}
@@ -864,6 +1011,9 @@ func (s *Server) aiNameChat(cfg aiConfig, owner, chat int64, question string) {
question = string(r[:600])
}
var b strings.Builder
if cfg.Type == "openai" {
cfg.think = "off" // a name needs no thinking
}
err := aiStream(ctx, cfg, "Придумай короткое название чата по первому вопросу пользователя: 2-3 слова, по-русски, "+
"без кавычек и точки в конце. Ответь только названием.", []aiMsg{{Role: "user", Content: question}}, func(t string) { b.WriteString(t) })
if err != nil {
@@ -999,6 +1149,11 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
if len(ms) > 0 {
b, _ := json.Marshal(ms)
_ = s.db.SetConfig(cfgAIModels, string(b))
// A model saved may be another one now: whether it thinks by itself
// is learned again.
for _, m := range ms {
_ = s.db.SetConfig(aiNativeKey(m.ID), "")
}
}
// The search engine for «Поиск».
eng := r.FormValue("ai_search")
+144
View File
@@ -0,0 +1,144 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// openAIChunk is one streamed delta of an OpenAI-compatible server.
func openAIChunk(field, text string) string {
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": map[string]string{field: text}}}})
return "data: " + string(b) + "\n\n"
}
// A model that thinks by itself (llama.cpp, vLLM: reasoning_content) has its
// thinking shown as the chat's own: between ::думаю:: and ::ответ::.
func TestAIOpenAINativeReasoning(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
fmt.Fprint(w, openAIChunk("reasoning_content", "17*23")+openAIChunk("reasoning_content", " = 391")+
openAIChunk("content", "391")+"data: [DONE]\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL, Model: "m", think: "on"})
if err != nil || got != "::думаю::\n17*23 = 391\n::ответ::\n391" {
t.Fatalf("got %q, %v", got, err)
}
kw, _ := body["chat_template_kwargs"].(map[string]any)
if kw["enable_thinking"] != true {
t.Fatalf("thinking not asked for: %v", body)
}
}
// A server that does not know the thinking switch (400 on it) is asked again
// without it.
func TestAIOpenAIRetriesWithoutThinkSwitch(t *testing.T) {
var mu sync.Mutex
var calls []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
mu.Lock()
calls = append(calls, b)
mu.Unlock()
if _, ok := b["chat_template_kwargs"]; ok {
http.Error(w, `{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`, 400)
return
}
fmt.Fprint(w, openAIChunk("content", "ok")+"data: [DONE]\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL, Model: "m", think: "off"})
if err != nil || got != "ok" || len(calls) != 2 {
t.Fatalf("got %q, %v, %d calls", got, err, len(calls))
}
}
// Anthropic: «Думать» turns on extended thinking; its thinking_delta is the
// thinking shown.
func TestAIAnthropicThinking(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
fmt.Fprint(w, "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"хм\"}}\n\n"+
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Да\"}}\n\n"+
"data: {\"type\":\"message_stop\"}\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "anthropic", URL: srv.URL, Key: "k", Model: "m", think: "on"})
if err != nil || got != "::думаю::\nхм\n::ответ::\nДа" {
t.Fatalf("got %q, %v", got, err)
}
th, _ := body["thinking"].(map[string]any)
if th["type"] != "enabled" || body["max_tokens"].(float64) <= th["budget_tokens"].(float64) {
t.Fatalf("thinking not asked for: %v", body)
}
}
// askThink sends one «Думать» question to the chat and gives the answer and
// the system prompts the model got, in order.
func askThink(t *testing.T, reply func(system string, body map[string]any) string) (string, []string) {
t.Helper()
s, c := newTestServer(t)
var mu sync.Mutex
var systems []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
sys := b["messages"].([]any)[0].(map[string]any)["content"].(string)
if strings.Contains(fmt.Sprint(b["messages"]), "название чата") || strings.Contains(sys, "название чата") {
fmt.Fprint(w, openAIChunk("content", "Чат")+"data: [DONE]\n\n")
return
}
mu.Lock()
systems = append(systems, sys)
mu.Unlock()
fmt.Fprint(w, reply(sys, b)+"data: [DONE]\n\n")
}))
t.Cleanup(srv.Close)
postAIModels(t, s, c, [][7]string{{"", "M", "openai", srv.URL, "m", "", ""}})
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"сколько 17*23","context":"x","think":true}`))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
mu.Lock()
defer mu.Unlock()
return w.Body.String(), append([]string(nil), systems...)
}
// A model with thinking of its own thinks once, by itself: no "think in
// words" prompt on top.
func TestAIChatThinkNative(t *testing.T) {
got, systems := askThink(t, func(sys string, b map[string]any) string {
return openAIChunk("reasoning_content", "считаю") + openAIChunk("content", "391")
})
if len(systems) != 1 || strings.Contains(systems[0], "Режим размышления") {
t.Fatalf("systems: %q", systems)
}
if !strings.Contains(got, "::думаю::\nсчитаю\n::ответ::\n391") {
t.Fatalf("answer: %q", got)
}
}
// A model without it is asked again with the prompt, its first answer
// dropped unseen.
func TestAIChatThinkPromptFallback(t *testing.T) {
got, systems := askThink(t, func(sys string, b map[string]any) string {
if strings.Contains(sys, "Режим размышления") {
return openAIChunk("content", "::думаю::\nсчитаю\n::ответ::\n391")
}
return openAIChunk("content", "сразу ответ")
})
if len(systems) != 2 || strings.Contains(systems[0], "Режим размышления") || !strings.Contains(systems[1], "Режим размышления") {
t.Fatalf("systems: %q", systems)
}
if strings.Contains(got, "сразу ответ") || !strings.Contains(got, "::ответ::\n391") {
t.Fatalf("answer: %q", got)
}
}