This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+15 -2
View File
@@ -9,8 +9,12 @@ import (
"github.com/anthropics/anthropic-sdk-go/option"
)
// Compile-time assertion: AnthropicClient must satisfy LLMClient.
var _ LLMClient = (*AnthropicClient)(nil)
// Compile-time assertion: AnthropicClient must satisfy LLMClient + Embedder
// (Embed returns ErrEmbeddingsUnsupported).
var (
_ LLMClient = (*AnthropicClient)(nil)
_ Embedder = (*AnthropicClient)(nil)
)
// AnthropicClient 通过官方 SDK 调用 Anthropic Messages API。
// baseURL 可改写以走 LiteLLM/OpenRouter 等代理。
@@ -152,3 +156,12 @@ func (c *AnthropicClient) CountTokens(ctx context.Context, modelID string, req R
}
return int(res.InputTokens), nil
}
// Embed is unsupported: Anthropic has no embeddings API. Embeddings must target
// an OpenAI-compatible or Gemini endpoint; callers degrade to keyword fallback.
func (c *AnthropicClient) Embed(context.Context, string, []string) ([][]float32, error) {
return nil, ErrEmbeddingsUnsupported
}
// EmbedDims is 0 since Anthropic produces no embeddings.
func (c *AnthropicClient) EmbedDims(string) int { return 0 }
+25
View File
@@ -0,0 +1,25 @@
package llm
import (
"context"
"errors"
)
// ErrEmbeddingsUnsupported is returned by providers that have no embeddings API
// (notably Anthropic). Callers degrade to keyword fallback when they see it.
var ErrEmbeddingsUnsupported = errors.New("llm: embeddings not supported by provider")
// PlatformEmbeddingDims is the single vector dimension baked into the pgvector
// columns at migration time (vector(1536)). Embedding endpoints MUST be
// configured with a model that produces this dimension; the build runner rejects
// mismatched dims rather than corrupting the index.
const PlatformEmbeddingDims = 1536
// Embedder is implemented by providers that expose a text-embeddings endpoint.
// Embed returns one vector per input, in input order. EmbedDims returns the
// vector length the given model produces (used to validate against the fixed
// pgvector column dimension before inserting).
type Embedder interface {
Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error)
EmbedDims(modelID string) int
}
+129
View File
@@ -0,0 +1,129 @@
package llm
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestOpenAI_Embed_ReturnsVectors(t *testing.T) {
var gotInputs []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Input []string `json:"input"`
Model string `json:"model"`
Dimensions int `json:"dimensions"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotInputs = body.Input
require.Equal(t, PlatformEmbeddingDims, body.Dimensions)
// Echo two 3-dim vectors (dimension count here is irrelevant to the test).
resp := map[string]any{
"object": "list",
"data": []map[string]any{
{"object": "embedding", "index": 0, "embedding": []float64{0.1, 0.2, 0.3}},
{"object": "embedding", "index": 1, "embedding": []float64{0.4, 0.5, 0.6}},
},
"model": body.Model,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
c, _ := NewOpenAI("k", srv.URL)
vecs, err := c.Embed(context.Background(), "text-embedding-3-small", []string{"alpha", "beta"})
require.NoError(t, err)
require.Len(t, vecs, 2)
require.Equal(t, []float32{0.1, 0.2, 0.3}, vecs[0])
require.Equal(t, []float32{0.4, 0.5, 0.6}, vecs[1])
require.Equal(t, []string{"alpha", "beta"}, gotInputs)
require.Equal(t, PlatformEmbeddingDims, c.EmbedDims("text-embedding-3-small"))
}
func TestOpenAI_Embed_RespectsResponseIndex(t *testing.T) {
// Server returns vectors out of order; Embed must place them by Index.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"object": "list",
"data": []map[string]any{
{"object": "embedding", "index": 1, "embedding": []float64{9, 9}},
{"object": "embedding", "index": 0, "embedding": []float64{1, 1}},
},
"model": "m",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
c, _ := NewOpenAI("k", srv.URL)
vecs, err := c.Embed(context.Background(), "m", []string{"a", "b"})
require.NoError(t, err)
require.Equal(t, []float32{1, 1}, vecs[0])
require.Equal(t, []float32{9, 9}, vecs[1])
}
func TestOpenAI_Embed_EmptyInputs(t *testing.T) {
c, _ := NewOpenAI("k", "")
vecs, err := c.Embed(context.Background(), "m", nil)
require.NoError(t, err)
require.Nil(t, vecs)
}
func TestAnthropic_Embed_Unsupported(t *testing.T) {
c, _ := NewAnthropic("k", "")
_, err := c.Embed(context.Background(), "m", []string{"x"})
require.ErrorIs(t, err, ErrEmbeddingsUnsupported)
require.Equal(t, 0, c.EmbedDims("m"))
}
func TestFakeEmbedder_Deterministic(t *testing.T) {
e := NewFakeEmbedder()
v1, err := e.Embed(context.Background(), "m", []string{"hello"})
require.NoError(t, err)
v2, err := e.Embed(context.Background(), "m", []string{"hello"})
require.NoError(t, err)
require.Equal(t, v1[0], v2[0], "same input must yield identical vector")
require.Len(t, v1[0], PlatformEmbeddingDims)
other, err := e.Embed(context.Background(), "m", []string{"world"})
require.NoError(t, err)
require.NotEqual(t, v1[0], other[0], "different inputs must differ")
}
func TestFakeEmbedder_Error(t *testing.T) {
e := &FakeEmbedder{Dims: 4, Err: errors.New("boom")}
_, err := e.Embed(context.Background(), "m", []string{"x"})
require.Error(t, err)
}
func TestRegistry_GetEmbedder_TypeAsserts(t *testing.T) {
// OpenAI client implements Embedder.
id := uuid.New()
reg := NewRegistry(&fakeLoader{endpoints: map[uuid.UUID]fakeEndpoint{
id: {ID: id, Provider: "openai", BaseURL: "http://localhost", APIKey: "k"},
}})
emb, err := reg.GetEmbedder(context.Background(), id)
require.NoError(t, err)
require.NotNil(t, emb)
require.Equal(t, PlatformEmbeddingDims, emb.EmbedDims("m"))
}
func TestRegistry_GetEmbedder_AnthropicSupportedButUnsupportedEmbed(t *testing.T) {
// Anthropic client implements Embedder (Embed returns ErrEmbeddingsUnsupported).
id := uuid.New()
reg := NewRegistry(&fakeLoader{endpoints: map[uuid.UUID]fakeEndpoint{
id: {ID: id, Provider: "anthropic", BaseURL: "http://localhost", APIKey: "k"},
}})
emb, err := reg.GetEmbedder(context.Background(), id)
require.NoError(t, err)
_, eerr := emb.Embed(context.Background(), "m", []string{"x"})
require.ErrorIs(t, eerr, ErrEmbeddingsUnsupported)
}
+59
View File
@@ -64,3 +64,62 @@ func (f *FakeClient) Stream(ctx context.Context, modelID string, req Request) (<
func (f *FakeClient) CountTokens(ctx context.Context, modelID string, req Request) (int, error) {
return f.TokenCount, nil
}
// FakeEmbedder is a deterministic Embedder for tests. Each input maps to a
// fixed-length vector derived from its bytes, so identical text always yields the
// same vector (lets tests assert idempotency / ranking without a real endpoint).
type FakeEmbedder struct {
Dims int // output dimension; defaults to PlatformEmbeddingDims when 0
Err error // if non-nil, Embed returns it
mu sync.Mutex
Calls int
Inputs []string // accumulated inputs across calls (for assertions)
}
// NewFakeEmbedder constructs a FakeEmbedder with the platform dimension.
func NewFakeEmbedder() *FakeEmbedder { return &FakeEmbedder{Dims: PlatformEmbeddingDims} }
var _ Embedder = (*FakeEmbedder)(nil)
func (e *FakeEmbedder) dims() int {
if e.Dims > 0 {
return e.Dims
}
return PlatformEmbeddingDims
}
// EmbedDims returns the configured dimension.
func (e *FakeEmbedder) EmbedDims(string) int { return e.dims() }
// Embed returns one deterministic vector per input. The vector is a normalized
// projection of a small hash of the input across the dimension, so distinct
// inputs are distinguishable and identical inputs are identical.
func (e *FakeEmbedder) Embed(_ context.Context, _ string, inputs []string) ([][]float32, error) {
e.mu.Lock()
e.Calls++
e.Inputs = append(e.Inputs, inputs...)
err := e.Err
e.mu.Unlock()
if err != nil {
return nil, err
}
d := e.dims()
out := make([][]float32, len(inputs))
for i, s := range inputs {
v := make([]float32, d)
// Seed from a stable rolling hash of the bytes.
var h uint32 = 2166136261
for _, b := range []byte(s) {
h ^= uint32(b)
h *= 16777619
}
for j := 0; j < d; j++ {
h ^= uint32(j) * 2654435761
h *= 16777619
// Map to [-1,1).
v[j] = float32(int32(h)) / float32(1<<31)
}
out[i] = v
}
return out, nil
}
+39 -2
View File
@@ -13,8 +13,11 @@ type GeminiClient struct {
sdk *genai.Client
}
// Compile-time assertion: GeminiClient must satisfy LLMClient.
var _ LLMClient = (*GeminiClient)(nil)
// Compile-time assertion: GeminiClient must satisfy LLMClient + Embedder.
var (
_ LLMClient = (*GeminiClient)(nil)
_ Embedder = (*GeminiClient)(nil)
)
// NewGemini 构造 GeminiClient。apiKey 不可空;baseURL 可空(用 SDK 默认端点)。
func NewGemini(apiKey, baseURL string) (*GeminiClient, error) {
@@ -142,3 +145,37 @@ func (c *GeminiClient) CountTokens(ctx context.Context, modelID string, req Requ
}
return int(res.TotalTokens), nil
}
// EmbedDims returns the platform-standard embedding dimension. gemini-embedding
// honors OutputDimensionality so Embed pins the output to PlatformEmbeddingDims.
func (c *GeminiClient) EmbedDims(string) int { return PlatformEmbeddingDims }
// Embed calls EmbedContent with all inputs batched as separate Contents, pinning
// OutputDimensionality to the fixed pgvector column width. Returns one vector per
// input in order.
func (c *GeminiClient) Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error) {
if len(inputs) == 0 {
return nil, nil
}
contents := make([]*genai.Content, len(inputs))
for i, in := range inputs {
contents[i] = &genai.Content{Role: "user", Parts: []*genai.Part{genai.NewPartFromText(in)}}
}
dims := int32(PlatformEmbeddingDims)
resp, err := c.sdk.Models.EmbedContent(ctx, modelID, contents, &genai.EmbedContentConfig{
OutputDimensionality: &dims,
})
if err != nil {
return nil, fmt.Errorf("gemini embeddings: %w", err)
}
if len(resp.Embeddings) != len(inputs) {
return nil, fmt.Errorf("gemini embeddings: got %d vectors for %d inputs", len(resp.Embeddings), len(inputs))
}
out := make([][]float32, len(resp.Embeddings))
for i, e := range resp.Embeddings {
v := make([]float32, len(e.Values))
copy(v, e.Values)
out[i] = v
}
return out, nil
}
+43 -3
View File
@@ -12,8 +12,11 @@ import (
tiktoken "github.com/pkoukk/tiktoken-go"
)
// Compile-time assertion: OpenAIClient must satisfy LLMClient.
var _ LLMClient = (*OpenAIClient)(nil)
// Compile-time assertion: OpenAIClient must satisfy LLMClient + Embedder.
var (
_ LLMClient = (*OpenAIClient)(nil)
_ Embedder = (*OpenAIClient)(nil)
)
// OpenAIClient 通过官方 SDK 调用 Chat Completions API。
// baseURL 改写后兼容 DeepSeek / Qwen / Ollama / vLLM / Moonshot 等 OpenAI-compatible 端点。
@@ -54,7 +57,7 @@ func toOpenAIMessages(msgs []Message) []openai.ChatCompletionMessageParamUnion {
parts = append(parts, openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{
URL: dataURI,
}))
// document/PDF 不翻译,由上层能力检查拦截
// document/PDF 不翻译,由上层能力检查拦截
}
}
out = append(out, openai.UserMessage(parts))
@@ -199,3 +202,40 @@ func (c *OpenAIClient) CountTokens(_ context.Context, modelID string, req Reques
}
return total, nil
}
// EmbedDims returns the platform-standard embedding dimension. text-embedding-3-*
// models honor the `dimensions` request param, so Embed always pins the output to
// PlatformEmbeddingDims to match the fixed pgvector column width.
func (c *OpenAIClient) EmbedDims(string) int { return PlatformEmbeddingDims }
// Embed calls POST /embeddings once for the whole batch (input order preserved)
// and converts the returned []float64 vectors to []float32 for pgvector storage.
func (c *OpenAIClient) Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error) {
if len(inputs) == 0 {
return nil, nil
}
resp, err := c.sdk.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: openai.EmbeddingModel(modelID),
Input: openai.EmbeddingNewParamsInputUnion{OfArrayOfStrings: inputs},
Dimensions: param.NewOpt(int64(PlatformEmbeddingDims)),
EncodingFormat: openai.EmbeddingNewParamsEncodingFormatFloat,
})
if err != nil {
return nil, fmt.Errorf("openai embeddings: %w", err)
}
if len(resp.Data) != len(inputs) {
return nil, fmt.Errorf("openai embeddings: got %d vectors for %d inputs", len(resp.Data), len(inputs))
}
out := make([][]float32, len(resp.Data))
for _, e := range resp.Data {
if e.Index < 0 || int(e.Index) >= len(out) {
return nil, fmt.Errorf("openai embeddings: out-of-range index %d", e.Index)
}
v := make([]float32, len(e.Embedding))
for i, f := range e.Embedding {
v[i] = float32(f)
}
out[e.Index] = v
}
return out, nil
}
+15
View File
@@ -63,6 +63,21 @@ func (r *Registry) GetClient(ctx context.Context, id uuid.UUID) (LLMClient, erro
return c, nil
}
// GetEmbedder 返回该 endpoint 对应的 Embedder(OpenAI / Gemini 实现;Anthropic
// 客户端虽实现接口但 Embed 返回 ErrEmbeddingsUnsupported)。底层复用 GetClient
// 的缓存与构造,再做类型断言。endpoint 的 client 未实现 Embedder 时返回错误。
func (r *Registry) GetEmbedder(ctx context.Context, id uuid.UUID) (Embedder, error) {
c, err := r.GetClient(ctx, id)
if err != nil {
return nil, err
}
emb, ok := c.(Embedder)
if !ok {
return nil, fmt.Errorf("llm registry: endpoint %s provider %q does not support embeddings", id, c.Provider())
}
return emb, nil
}
// Invalidate 删除该 endpoint 的缓存条目。endpoint CRUD 后由 service 层调用。
func (r *Registry) Invalidate(id uuid.UUID) {
r.mu.Lock()