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:
1 parent
37ef886503
commit
03f963340e
2 files changed
+309
-10
No files matched your search
+165
-10
@@ -39,6 +39,10 @@ const (
|
|||||||
DefaultAIURL = "https://agentproxy.minomix.ru"
|
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
|
// 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.
|
// default; an explicit "0" means the user turned it off.
|
||||||
func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) }
|
func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) }
|
||||||
@@ -54,6 +58,10 @@ type aiConfig struct {
|
|||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Key string `json:"key,omitempty"`
|
Key string `json:"key,omitempty"`
|
||||||
Model string `json:"model,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.
|
// 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()
|
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) {
|
func aiPost(ctx context.Context, url string, body any, hdr map[string]string) (*http.Response, error) {
|
||||||
b, _ := json.Marshal(body)
|
b, _ := json.Marshal(body)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
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 {
|
if resp.StatusCode/100 != 2 {
|
||||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
|
||||||
resp.Body.Close()
|
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
|
return resp, nil
|
||||||
}
|
}
|
||||||
@@ -226,10 +287,21 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
|
|||||||
if cfg.Key != "" {
|
if cfg.Key != "" {
|
||||||
hdr["Authorization"] = "Bearer " + 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
tw := &thinkWrap{emit: emit}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return sseData(resp.Body, func(d string) (bool, error) {
|
return sseData(resp.Body, func(d string) (bool, error) {
|
||||||
if d == "[DONE]" {
|
if d == "[DONE]" {
|
||||||
@@ -239,6 +311,8 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
|
|||||||
Choices []struct {
|
Choices []struct {
|
||||||
Delta struct {
|
Delta struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
Error *struct {
|
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)
|
return true, errors.New(m.Error.Message)
|
||||||
}
|
}
|
||||||
for _, c := range m.Choices {
|
for _, c := range m.Choices {
|
||||||
if c.Delta.Content != "" {
|
tw.think(c.Delta.ReasoningContent + c.Delta.Reasoning)
|
||||||
emit(c.Delta.Content)
|
tw.text(c.Delta.Content)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false, nil
|
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})
|
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},
|
body := 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"})
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
tw := &thinkWrap{emit: emit}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return sseData(resp.Body, func(d string) (bool, error) {
|
return sseData(resp.Body, func(d string) (bool, error) {
|
||||||
var m struct {
|
var m struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Delta struct {
|
Delta struct {
|
||||||
|
Type string `json:"type"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
Thinking string `json:"thinking"`
|
||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
Error struct {
|
Error struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -301,7 +389,11 @@ func aiAnthropic(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg,
|
|||||||
}
|
}
|
||||||
switch m.Type {
|
switch m.Type {
|
||||||
case "content_block_delta":
|
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":
|
case "message_stop":
|
||||||
return true, nil
|
return true, nil
|
||||||
case "error":
|
case "error":
|
||||||
@@ -807,13 +899,35 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.Search {
|
if in.Search {
|
||||||
system += searchPrompt(s.searchConf())
|
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
|
system += aiThink
|
||||||
// Models heed the last words most: the reminder goes there too (not
|
// Models heed the last words most: the reminder goes there too (not
|
||||||
// kept with the talk).
|
// kept with the talk).
|
||||||
if n := len(msgs); n > 0 && msgs[n-1].Role == "user" {
|
if n := len(msgs); n > 0 && msgs[n-1].Role == "user" {
|
||||||
msgs[n-1].Content += "\n\n(Сначала подумай: первая строка ответа - ::думаю::, дальше рассуждение, потом строка ::ответ:: и сам ответ.)"
|
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("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
@@ -836,7 +950,40 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
fl.Flush()
|
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
|
// The answer is kept whole, thinking too (to show it again); the model
|
||||||
// gets it without (aiWindow).
|
// gets it without (aiWindow).
|
||||||
ans := aiMsg{Role: "assistant", Content: answer.String()}
|
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])
|
question = string(r[:600])
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
if cfg.Type == "openai" {
|
||||||
|
cfg.think = "off" // a name needs no thinking
|
||||||
|
}
|
||||||
err := aiStream(ctx, cfg, "Придумай короткое название чата по первому вопросу пользователя: 2-3 слова, по-русски, "+
|
err := aiStream(ctx, cfg, "Придумай короткое название чата по первому вопросу пользователя: 2-3 слова, по-русски, "+
|
||||||
"без кавычек и точки в конце. Ответь только названием.", []aiMsg{{Role: "user", Content: question}}, func(t string) { b.WriteString(t) })
|
"без кавычек и точки в конце. Ответь только названием.", []aiMsg{{Role: "user", Content: question}}, func(t string) { b.WriteString(t) })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -999,6 +1149,11 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(ms) > 0 {
|
if len(ms) > 0 {
|
||||||
b, _ := json.Marshal(ms)
|
b, _ := json.Marshal(ms)
|
||||||
_ = s.db.SetConfig(cfgAIModels, string(b))
|
_ = 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 «Поиск».
|
// The search engine for «Поиск».
|
||||||
eng := r.FormValue("ai_search")
|
eng := r.FormValue("ai_search")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user