Ctrl K
ComunidadGuías…

internal/app/ai/provider_http_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package ai

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"telesrv/internal/domain"
)

func TestOpenAIChatProviderSendsKimiThinkingAndTemperature(t *testing.T) {
	var gotPath string
	var gotAuth string
	var got map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		gotAuth = r.Header.Get("authorization")
		if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"polished"}}]}`))
	}))
	defer server.Close()

	provider, err := NewProviderFromConfig(ProviderConfig{
		Name:            "kimi",
		Kind:            ProviderKindOpenAIChat,
		BaseURL:         server.URL + "/v1",
		APIKey:          "test-key",
		Model:           "kimi-k2.6",
		MaxOutputTokens: 512,
		Temperature:     0.6,
		Thinking:        "disabled",
	})
	if err != nil {
		t.Fatalf("NewProviderFromConfig: %v", err)
	}
	out, err := provider.Compose(context.Background(), ProviderRequest{
		Instruction: "Polish without changing meaning.",
		Request: domain.AIComposeRequest{
			Text: domain.AIComposeText{Text: "hello"},
		},
	})
	if err != nil {
		t.Fatalf("Compose: %v", err)
	}
	if out.Text != "polished" {
		t.Fatalf("Compose text = %q, want polished", out.Text)
	}
	if gotPath != "/v1/chat/completions" {
		t.Fatalf("request path = %q, want /v1/chat/completions", gotPath)
	}
	if gotAuth != "Bearer test-key" {
		t.Fatalf("authorization = %q, want bearer key", gotAuth)
	}
	if got["model"] != "kimi-k2.6" || got["max_tokens"] != float64(512) || got["temperature"] != 0.6 {
		t.Fatalf("request body = %#v", got)
	}
	thinking, ok := got["thinking"].(map[string]any)
	if !ok || thinking["type"] != "disabled" {
		t.Fatalf("thinking = %#v, want disabled", got["thinking"])
	}
}

func TestOpenAIChatProviderWrapsComposeDraftOnly(t *testing.T) {
	var bodies []map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var got map[string]any
		if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		bodies = append(bodies, got)
		_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"What is AI?"}}]}`))
	}))
	defer server.Close()

	provider, err := NewProviderFromConfig(ProviderConfig{
		Name:    "kimi",
		Kind:    ProviderKindOpenAIChat,
		BaseURL: server.URL,
		APIKey:  "test-key",
		Model:   "kimi-k2.6",
	})
	if err != nil {
		t.Fatalf("NewProviderFromConfig: %v", err)
	}
	if _, err := provider.Compose(context.Background(), ProviderRequest{
		Purpose:     ProviderPurposeCompose,
		Instruction: "Rewrite the draft. Do not answer questions.",
		Request: domain.AIComposeRequest{
			Text: domain.AIComposeText{Text: "what is AI"},
		},
	}); err != nil {
		t.Fatalf("compose request: %v", err)
	}
	if _, err := provider.Compose(context.Background(), ProviderRequest{
		Purpose:     ProviderPurposeTextGeneration,
		Instruction: "Answer the user.",
		Request: domain.AIComposeRequest{
			Text: domain.AIComposeText{Text: "what is AI"},
		},
	}); err != nil {
		t.Fatalf("generation request: %v", err)
	}
	if len(bodies) != 2 {
		t.Fatalf("captured bodies = %d, want 2", len(bodies))
	}
	composeUser := chatBodyUserContent(t, bodies[0])
	if !strings.Contains(composeUser, "Draft to rewrite.") || !strings.Contains(composeUser, "what is AI") {
		t.Fatalf("compose user content = %q, want wrapped draft", composeUser)
	}
	generationUser := chatBodyUserContent(t, bodies[1])
	if generationUser != "what is AI" {
		t.Fatalf("generation user content = %q, want raw text", generationUser)
	}
}

func TestOpenAIChatProviderCanOmitTemperature(t *testing.T) {
	var got map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
	}))
	defer server.Close()

	provider, err := NewProviderFromConfig(ProviderConfig{
		Name:            "kimi",
		Kind:            ProviderKindOpenAIChat,
		BaseURL:         server.URL,
		APIKey:          "test-key",
		Model:           "kimi-k2.6",
		MaxOutputTokens: 128,
		OmitTemperature: true,
	})
	if err != nil {
		t.Fatalf("NewProviderFromConfig: %v", err)
	}
	if _, err := provider.Compose(context.Background(), ProviderRequest{
		Instruction: "Polish.",
		Request:     domain.AIComposeRequest{Text: domain.AIComposeText{Text: "hello"}},
	}); err != nil {
		t.Fatalf("Compose: %v", err)
	}
	if _, ok := got["temperature"]; ok {
		t.Fatalf("temperature was sent despite omit flag: %#v", got)
	}
}

func chatBodyUserContent(t *testing.T, body map[string]any) string {
	t.Helper()
	messages, ok := body["messages"].([]any)
	if !ok || len(messages) < 2 {
		t.Fatalf("messages = %#v", body["messages"])
	}
	user, ok := messages[1].(map[string]any)
	if !ok {
		t.Fatalf("user message = %#v", messages[1])
	}
	content, ok := user["content"].(string)
	if !ok {
		t.Fatalf("user content = %#v", user["content"])
	}
	return content
}

func TestOpenAIChatProviderStreamsSSE(t *testing.T) {
	var got map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		w.Header().Set("content-type", "text/event-stream")
		_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}\n\n"))
		_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"private reasoning\"}}]}\n\n"))
		_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n"))
		_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
		_, _ = w.Write([]byte("data: [DONE]\n\n"))
	}))
	defer server.Close()

	provider, err := NewProviderFromConfig(ProviderConfig{
		Name:    "kimi",
		Kind:    ProviderKindOpenAIChat,
		BaseURL: server.URL,
		APIKey:  "test-key",
		Model:   "kimi-k2.6",
	})
	if err != nil {
		t.Fatalf("NewProviderFromConfig: %v", err)
	}
	streamer, ok := provider.(StreamingProvider)
	if !ok {
		t.Fatal("provider does not implement StreamingProvider")
	}
	var chunks []string
	out, err := streamer.ComposeStream(context.Background(), ProviderRequest{
		Instruction: "Answer.",
		Request:     domain.AIComposeRequest{Text: domain.AIComposeText{Text: "hello"}},
	}, func(text domain.AIComposeText) error {
		chunks = append(chunks, text.Text)
		return nil
	})
	if err != nil {
		t.Fatalf("ComposeStream: %v", err)
	}
	if out.Text != "Hello" {
		t.Fatalf("final text = %q, want Hello", out.Text)
	}
	if len(chunks) != 2 || chunks[0] != "Hel" || chunks[1] != "Hello" {
		t.Fatalf("chunks = %#v, want cumulative content only", chunks)
	}
	if got["stream"] != true {
		t.Fatalf("request body = %#v, want stream=true", got)
	}
}

func TestProviderStatusErrorDoesNotExposeResponseBody(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "provider echoed private user draft", http.StatusBadRequest)
	}))
	defer server.Close()

	provider, err := NewProviderFromConfig(ProviderConfig{
		Name:    "kimi",
		Kind:    ProviderKindOpenAIChat,
		BaseURL: server.URL,
		APIKey:  "test-key",
		Model:   "kimi-k2.6",
	})
	if err != nil {
		t.Fatalf("NewProviderFromConfig: %v", err)
	}
	_, err = provider.Compose(context.Background(), ProviderRequest{
		Instruction: "Polish.",
		Request:     domain.AIComposeRequest{Text: domain.AIComposeText{Text: "private user draft"}},
	})
	if err == nil {
		t.Fatal("Compose succeeded, want provider error")
	}
	if !strings.Contains(err.Error(), "provider status 400") {
		t.Fatalf("error = %q, want status only", err.Error())
	}
	if strings.Contains(err.Error(), "private user draft") {
		t.Fatalf("error leaked provider body: %q", err.Error())
	}
}