Backend.Upsert never received the fragment's Content, so ChromaDB (and any backend) stored the vector but silently dropped the actual text — saved memories had nothing to retrieve later. Backend.Search now also takes the raw query text, and a failed/missing embedding no longer hard-fails Add/Search: it degrades to a nil vector so a lexical-capable backend can still index/find the content (Chroma has no such fallback and now says so explicitly instead of misbehaving). Adds pkg/rag/backends/sqlitevec: a zero-dependency backend (pure-Go SQLite, no external service) that does cosine similarity when a real embedding vector is available and falls back to FTS5/BM25 full-text search otherwise. Adds pkg/rag/embeddings.OpenAICompatible, covering both a local llama.cpp server (`--embeddings` enabled) and real OpenAI (or any OpenAI-shaped /embeddings endpoint) through the same client. Also fixes token usage tracking for llama.cpp streaming: the client never requested `stream_options.include_usage` nor parsed a usage-only SSE event, and even when present, the agent loop's RunStream dropped any chunk with no Delta/ReasoningDelta — silently discarding the only chunk that carries usage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
134 lines
4 KiB
Go
134 lines
4 KiB
Go
package embeddings
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestNewOpenAICompatible_RequiresBaseURL(t *testing.T) {
|
|
_, err := NewOpenAICompatible(OpenAICompatibleConfig{})
|
|
if err == nil {
|
|
t.Fatal("expected error when BaseURL is empty")
|
|
}
|
|
}
|
|
|
|
func TestNewLlamaCpp_Defaults(t *testing.T) {
|
|
e, err := NewLlamaCpp(LlamaCppConfig{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if e.baseURL != "http://localhost:8080/v1" {
|
|
t.Errorf("expected default base URL, got %q", e.baseURL)
|
|
}
|
|
}
|
|
|
|
func TestNewLlamaCpp_Custom(t *testing.T) {
|
|
e, err := NewLlamaCpp(LlamaCppConfig{BaseURL: "http://custom:9000/v1", Model: "my-model"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if e.baseURL != "http://custom:9000/v1" {
|
|
t.Errorf("expected custom base URL, got %q", e.baseURL)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatible_Embed_Success(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/embeddings" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"data":[{"embedding":[0.1,0.2,0.3],"index":0}]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
e, err := NewLlamaCpp(LlamaCppConfig{BaseURL: server.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
vector, err := e.Embed(context.Background(), "hola")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(vector) != 3 {
|
|
t.Fatalf("expected 3 dimensions, got %d", len(vector))
|
|
}
|
|
if e.Dimensions() != 3 {
|
|
t.Errorf("expected Dimensions() to learn 3 after a successful call, got %d", e.Dimensions())
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatible_Embed_NotEnabled(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotImplemented)
|
|
w.Write([]byte(`{"error":{"message":"This server does not support embeddings. Start it with ` + "`--embeddings`" + `"}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
e, err := NewLlamaCpp(LlamaCppConfig{BaseURL: server.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if _, err := e.Embed(context.Background(), "hola"); err == nil {
|
|
t.Fatal("expected error when the server doesn't support embeddings")
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatible_SendsBearerTokenWhenConfigured(t *testing.T) {
|
|
var gotAuth string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"data":[{"embedding":[0.1],"index":0}]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
e, err := NewOpenAICompatible(OpenAICompatibleConfig{
|
|
BaseURL: server.URL,
|
|
APIKey: "sk-test",
|
|
Model: "text-embedding-3-small",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if _, err := e.Embed(context.Background(), "hola"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotAuth != "Bearer sk-test" {
|
|
t.Fatalf("expected Authorization header to be sent, got %q", gotAuth)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatible_NoAuthHeaderWhenNoAPIKey(t *testing.T) {
|
|
var gotAuth string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"data":[{"embedding":[0.1],"index":0}]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
e, err := NewOpenAICompatible(OpenAICompatibleConfig{BaseURL: server.URL})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if _, err := e.Embed(context.Background(), "hola"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotAuth != "" {
|
|
t.Fatalf("expected no Authorization header without an API key, got %q", gotAuth)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatible_Dimensions_BeforeAnyCall(t *testing.T) {
|
|
e, err := NewLlamaCpp(LlamaCppConfig{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if dims := e.Dimensions(); dims != 0 {
|
|
t.Errorf("expected 0 dimensions before any successful call, got %d", dims)
|
|
}
|
|
}
|