You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user