From 03f963340e3b099ed154e56cdcde2c6dc549e826 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Sat, 17 Oct 2026 15:36:35 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=C2=AB=D0=94=D1=83=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=C2=BB=20uses=20a=20model's=20own=20thinking=20when=20it?= =?UTF-8?q?=20has=20it=20(OpenAI-compatible=20reasoning,=20Anthropic=20ext?= =?UTF-8?q?ended=20thinking)=20instead=20of=20asking=20it=20to=20think=20i?= =?UTF-8?q?n=20words=20too;=20learned=20per=20model,=20the=20word-thinking?= =?UTF-8?q?=20prompt=20kept=20for=20models=20without;=20without=20=C2=AB?= =?UTF-8?q?=D0=94=D1=83=D0=BC=D0=B0=D1=82=D1=8C=C2=BB=20such=20a=20model?= =?UTF-8?q?=20is=20asked=20not=20to=20think?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/ai.go | 179 +++++++++++++++++++++++++++++--- internal/server/aithink_test.go | 144 +++++++++++++++++++++++++ 2 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 internal/server/aithink_test.go diff --git a/internal/server/ai.go b/internal/server/ai.go index 0d7dc32..81a121c 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -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") diff --git a/internal/server/aithink_test.go b/internal/server/aithink_test.go new file mode 100644 index 0000000..64e023f --- /dev/null +++ b/internal/server/aithink_test.go @@ -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) + } +}