145 lines
5.6 KiB
Go
145 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|