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) } }