feat: sync AI compose and ChatBot features
This commit is contained in:
parent
35e5d38f4d
commit
b7269b135f
75 changed files with 5426 additions and 123 deletions
251
internal/app/ai/provider_http_test.go
Normal file
251
internal/app/ai/provider_http_test.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue